@hanzlaa/rcode 4.1.2 → 4.3.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/cli/install.js +176 -13
- package/cli/lib/config.cjs +4 -2
- package/cli/lib/fsutil.cjs +13 -2
- package/cli/lib/homedir.cjs +21 -0
- package/cli/lib/schemas.cjs +6 -1
- package/cli/nuke.js +13 -8
- package/cli/postinstall.js +14 -4
- package/cli/rcode-slash-router.cjs +118 -0
- package/cli/uninstall.js +59 -1
- package/cli/update.js +10 -5
- package/dist/rcode.js +234 -230
- package/package.json +1 -1
- package/server/dashboard.js +26 -7
- package/server/lib/api.js +62 -4
- package/server/lib/html/client/agents-data.js +22 -18
- package/server/lib/html/client/app.js +3 -0
- package/server/lib/html/client/components/AgentCard.js +127 -0
- package/server/lib/html/client/components/App.js +104 -39
- package/server/lib/html/client/components/CommandPalette.js +133 -0
- package/server/lib/html/client/components/FileReader.js +116 -0
- package/server/lib/html/client/components/FilterChips.js +94 -0
- package/server/lib/html/client/components/NotifyCenter.js +117 -0
- package/server/lib/html/client/components/OrchPanel.js +80 -52
- package/server/lib/html/client/components/PhaseGraph.js +300 -0
- package/server/lib/html/client/components/RejectDialog.js +78 -0
- package/server/lib/html/client/components/RunnerPicker.js +190 -0
- package/server/lib/html/client/components/Sidebar.js +106 -61
- package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
- package/server/lib/html/client/components/TaskPipeline.js +83 -0
- package/server/lib/html/client/components/Topbar.js +86 -39
- package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
- package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
- package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
- package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
- package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
- package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
- package/server/lib/html/client/components/shared.js +47 -11
- package/server/lib/html/client/filter-state.js +72 -0
- package/server/lib/html/client/icons-client.js +7 -0
- package/server/lib/html/client/notify.js +75 -0
- package/server/lib/html/client/orchestrator.js +168 -41
- package/server/lib/html/client/preact.js +13 -8
- package/server/lib/html/client/store.js +70 -6
- package/server/lib/html/client/util.js +78 -0
- package/server/lib/html/client/vendor/htm.js +1 -0
- package/server/lib/html/client/vendor/preact-hooks.js +2 -0
- package/server/lib/html/client/vendor/preact.js +2 -0
- package/server/lib/html/client/views/AgentsView.js +144 -51
- package/server/lib/html/client/views/FilesView.js +20 -103
- package/server/lib/html/client/views/KanbanView.js +40 -21
- package/server/lib/html/client/views/MemoryView.js +26 -9
- package/server/lib/html/client/views/MilestonesView.js +4 -4
- package/server/lib/html/client/views/OrchestrationView.js +154 -19
- package/server/lib/html/client/views/OverviewView.js +47 -239
- package/server/lib/html/client/views/PhasesView.js +50 -6
- package/server/lib/html/client/views/RoadmapView.js +6 -3
- package/server/lib/html/client/views/SprintsView.js +50 -6
- package/server/lib/html/client/views/TasksView.js +4 -3
- package/server/lib/html/client.js +21 -4
- package/server/lib/html/css.js +2761 -8
- package/server/lib/html/icons.js +7 -0
- package/server/lib/html/shell.js +10 -3
- package/server/lib/scanner.js +376 -39
- package/server/orchestrator.js +329 -5
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PhaseGraph — phase dependency graph, hand-rolled inline SVG (no libs).
|
|
3
|
+
*
|
|
4
|
+
* Layout: layered DAG. Each phase gets a layer = 0 when it has no resolvable
|
|
5
|
+
* dependencies, else 1 + max(layer of each dependency) — roots on the left,
|
|
6
|
+
* dependents to the right. A layer with more than MAX_ROWS phases wraps into
|
|
7
|
+
* adjacent sub-columns so 34+ phase milestones stay readable instead of
|
|
8
|
+
* producing one mile-high column. Layering is iterative with a pass cap and
|
|
9
|
+
* monotonic updates, so a dependency cycle in bad data cannot loop forever.
|
|
10
|
+
*
|
|
11
|
+
* Honest states:
|
|
12
|
+
* - no phases → friendly empty message
|
|
13
|
+
* - no cross-phase deps → simple wrapped flow row of chips (no fake DAG)
|
|
14
|
+
* - real deps → layered DAG with curved edges + arrowheads
|
|
15
|
+
*
|
|
16
|
+
* Interactions: hover highlights a node's ancestors + descendants and dims
|
|
17
|
+
* the rest; click navigates to the phase; an SVG-rendered tooltip shows the
|
|
18
|
+
* full name, sprint count and dependency list. The SVG sits in a horizontal
|
|
19
|
+
* scroll container for wide graphs.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { html, useState, useMemo } from '../preact.js';
|
|
23
|
+
import { Icon } from '../icons-client.js';
|
|
24
|
+
|
|
25
|
+
const NODE_W = 150, NODE_H = 44; // capped chip size — names truncate to fit
|
|
26
|
+
const COL_GAP = 56, ROW_GAP = 14;
|
|
27
|
+
const PAD = 16;
|
|
28
|
+
const MAX_ROWS = 8; // rows per column before a layer wraps
|
|
29
|
+
|
|
30
|
+
/** Collapse the many status spellings into the four visual kinds. */
|
|
31
|
+
export function statusKind(status) {
|
|
32
|
+
const s = String(status || '');
|
|
33
|
+
if (/blocked/i.test(s)) return 'blocked';
|
|
34
|
+
if (/complete|done/i.test(s)) return 'done';
|
|
35
|
+
if (/active|executing|in_progress|progress/i.test(s)) return 'active';
|
|
36
|
+
return 'todo';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Dependencies that resolve to a known phase (self-references dropped). */
|
|
40
|
+
function resolvedDeps(p, known) {
|
|
41
|
+
return (p.dependsOn || []).map(String)
|
|
42
|
+
.filter(d => known.has(d) && d !== String(p.id));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Topological layering. Returns Map(id → layer). Monotonic updates + a pass
|
|
47
|
+
* cap of phases.length guarantee termination even on cyclic input.
|
|
48
|
+
*/
|
|
49
|
+
export function computeLayers(phases) {
|
|
50
|
+
const known = new Set(phases.map(p => String(p.id)));
|
|
51
|
+
const layers = new Map(phases.map(p => [String(p.id), 0]));
|
|
52
|
+
for (let pass = 0; pass < phases.length; pass++) {
|
|
53
|
+
let changed = false;
|
|
54
|
+
for (const p of phases) {
|
|
55
|
+
const deps = resolvedDeps(p, known);
|
|
56
|
+
if (!deps.length) continue;
|
|
57
|
+
const next = 1 + Math.max(...deps.map(d => layers.get(d) || 0));
|
|
58
|
+
if (next > (layers.get(String(p.id)) || 0)) {
|
|
59
|
+
layers.set(String(p.id), next);
|
|
60
|
+
changed = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!changed) break;
|
|
64
|
+
}
|
|
65
|
+
return layers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** parents/children adjacency over resolvable dependencies. */
|
|
69
|
+
function buildAdjacency(phases) {
|
|
70
|
+
const known = new Set(phases.map(p => String(p.id)));
|
|
71
|
+
const parents = new Map(), children = new Map();
|
|
72
|
+
for (const p of phases) {
|
|
73
|
+
const id = String(p.id);
|
|
74
|
+
const deps = resolvedDeps(p, known);
|
|
75
|
+
parents.set(id, deps);
|
|
76
|
+
for (const d of deps) {
|
|
77
|
+
if (!children.has(d)) children.set(d, []);
|
|
78
|
+
children.get(d).push(id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { parents, children };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** BFS one direction (parents = ancestors, children = descendants). */
|
|
85
|
+
function reach(start, adj) {
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
const queue = [start];
|
|
88
|
+
while (queue.length) {
|
|
89
|
+
const cur = queue.shift();
|
|
90
|
+
for (const next of adj.get(cur) || []) {
|
|
91
|
+
if (!seen.has(next)) { seen.add(next); queue.push(next); }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return seen;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Hovered node + every ancestor and descendant of it. */
|
|
98
|
+
function relatedSet(id, { parents, children }) {
|
|
99
|
+
const set = new Set([id]);
|
|
100
|
+
for (const a of reach(id, parents)) set.add(a);
|
|
101
|
+
for (const d of reach(id, children)) set.add(d);
|
|
102
|
+
return set;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function truncate(text, max) {
|
|
106
|
+
const s = String(text || '');
|
|
107
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function goToPhase(id) { location.hash = 'phases/' + id; }
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Pixel layout for the DAG mode. Layers become columns left→right; a layer
|
|
114
|
+
* larger than MAX_ROWS wraps into adjacent sub-columns. All values derive
|
|
115
|
+
* from integer counts, so positions are always finite — no NaN, no overlap.
|
|
116
|
+
*/
|
|
117
|
+
function layout(phases, layers) {
|
|
118
|
+
const byLayer = new Map();
|
|
119
|
+
for (const p of phases) {
|
|
120
|
+
const l = layers.get(String(p.id)) || 0;
|
|
121
|
+
if (!byLayer.has(l)) byLayer.set(l, []);
|
|
122
|
+
byLayer.get(l).push(p);
|
|
123
|
+
}
|
|
124
|
+
const order = [...byLayer.keys()].sort((a, b) => a - b);
|
|
125
|
+
|
|
126
|
+
const pos = new Map();
|
|
127
|
+
let col = 0, maxRowsUsed = 1;
|
|
128
|
+
for (const l of order) {
|
|
129
|
+
const group = byLayer.get(l);
|
|
130
|
+
const subCols = Math.max(1, Math.ceil(group.length / MAX_ROWS));
|
|
131
|
+
const perCol = Math.ceil(group.length / subCols);
|
|
132
|
+
group.forEach((p, i) => {
|
|
133
|
+
const sub = Math.floor(i / perCol);
|
|
134
|
+
const row = i % perCol;
|
|
135
|
+
maxRowsUsed = Math.max(maxRowsUsed, row + 1);
|
|
136
|
+
pos.set(String(p.id), {
|
|
137
|
+
x: PAD + (col + sub) * (NODE_W + COL_GAP),
|
|
138
|
+
y: PAD + row * (NODE_H + ROW_GAP),
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
col += subCols;
|
|
142
|
+
}
|
|
143
|
+
const width = PAD * 2 + col * NODE_W + Math.max(0, col - 1) * COL_GAP;
|
|
144
|
+
const height = PAD * 2 + maxRowsUsed * NODE_H + (maxRowsUsed - 1) * ROW_GAP;
|
|
145
|
+
return { pos, width, height };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Tooltip box rendered inside the SVG, clamped to stay within the canvas. */
|
|
149
|
+
function Tooltip({ phase, nodePos, canvasW, canvasH }) {
|
|
150
|
+
const name = truncate(phase.name, 48);
|
|
151
|
+
const sprints = (phase.sprints || []).length;
|
|
152
|
+
const deps = (phase.dependsOn || []).map(String);
|
|
153
|
+
const lines = [
|
|
154
|
+
name,
|
|
155
|
+
sprints + (sprints === 1 ? ' sprint' : ' sprints'),
|
|
156
|
+
deps.length ? 'Needs: ' + deps.map(d => 'P' + d).join(', ') : 'No dependencies',
|
|
157
|
+
];
|
|
158
|
+
const w = Math.min(330, Math.max(150, Math.max(...lines.map(l => l.length)) * 6.2 + 24));
|
|
159
|
+
const h = lines.length * 15 + 14;
|
|
160
|
+
const x = Math.max(4, Math.min(nodePos.x, canvasW - w - 4));
|
|
161
|
+
let y = nodePos.y + NODE_H + 8;
|
|
162
|
+
if (y + h > canvasH - 4) y = Math.max(4, nodePos.y - h - 8);
|
|
163
|
+
return html`
|
|
164
|
+
<g class="pg-tip">
|
|
165
|
+
<rect x=${x} y=${y} width=${w} height=${h} rx="6"/>
|
|
166
|
+
${lines.map((line, i) => html`
|
|
167
|
+
<text key=${i} x=${x + 12} y=${y + 20 + i * 15}
|
|
168
|
+
class=${i === 0 ? 'pg-tip-title' : 'pg-tip-line'}>${line}</text>
|
|
169
|
+
`)}
|
|
170
|
+
</g>
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Wrapped flow row of chips — the honest no-dependencies presentation. */
|
|
175
|
+
function FlowRow({ phases }) {
|
|
176
|
+
return html`
|
|
177
|
+
<div class="pg-flow">
|
|
178
|
+
${phases.map(p => {
|
|
179
|
+
const kind = statusKind(p.status);
|
|
180
|
+
const sprints = (p.sprints || []).length;
|
|
181
|
+
return html`
|
|
182
|
+
<button key=${p.id} type="button"
|
|
183
|
+
class=${'pg-chip pg-' + kind}
|
|
184
|
+
title=${(p.name || '') + ' — ' + sprints + (sprints === 1 ? ' sprint' : ' sprints')}
|
|
185
|
+
onClick=${() => goToPhase(p.id)}>
|
|
186
|
+
<span class="pg-chip-id">P${p.id}</span>
|
|
187
|
+
<span class="pg-chip-name">${truncate(p.name, 24)}</span>
|
|
188
|
+
</button>
|
|
189
|
+
`;
|
|
190
|
+
})}
|
|
191
|
+
</div>
|
|
192
|
+
<div class="pg-hint">No cross-phase dependencies declared — phases shown in roadmap order.</div>
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Layered DAG with curved edges, hover ancestry highlighting and tooltips. */
|
|
197
|
+
function Dag({ phases }) {
|
|
198
|
+
const [hoverId, setHoverId] = useState(null);
|
|
199
|
+
|
|
200
|
+
const model = useMemo(() => {
|
|
201
|
+
const layers = computeLayers(phases);
|
|
202
|
+
const { pos, width, height } = layout(phases, layers);
|
|
203
|
+
const adjacency = buildAdjacency(phases);
|
|
204
|
+
const known = new Set(phases.map(p => String(p.id)));
|
|
205
|
+
const edges = [];
|
|
206
|
+
for (const p of phases) {
|
|
207
|
+
for (const d of resolvedDeps(p, known)) {
|
|
208
|
+
const from = pos.get(d), to = pos.get(String(p.id));
|
|
209
|
+
if (!from || !to) continue;
|
|
210
|
+
edges.push({ from: d, to: String(p.id), x1: from.x + NODE_W, y1: from.y + NODE_H / 2, x2: to.x, y2: to.y + NODE_H / 2 });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { pos, width, height, adjacency, edges };
|
|
214
|
+
}, [phases]);
|
|
215
|
+
|
|
216
|
+
const related = useMemo(
|
|
217
|
+
() => (hoverId ? relatedSet(hoverId, model.adjacency) : null),
|
|
218
|
+
[hoverId, model],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const hovered = hoverId ? phases.find(p => String(p.id) === hoverId) : null;
|
|
222
|
+
|
|
223
|
+
return html`
|
|
224
|
+
<div class="pg-scroll">
|
|
225
|
+
<svg class=${'pg-svg' + (hoverId ? ' pg-hovering' : '')}
|
|
226
|
+
width=${model.width} height=${model.height}
|
|
227
|
+
viewBox=${'0 0 ' + model.width + ' ' + model.height}
|
|
228
|
+
role="img" aria-label="Phase dependency graph">
|
|
229
|
+
<defs>
|
|
230
|
+
<marker id="pg-arrow" markerWidth="8" markerHeight="8"
|
|
231
|
+
refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
|
232
|
+
<path class="pg-arrow" d="M0,0 L8,4 L0,8 Z"/>
|
|
233
|
+
</marker>
|
|
234
|
+
</defs>
|
|
235
|
+
${model.edges.map(e => {
|
|
236
|
+
const bend = Math.max(24, (e.x2 - e.x1) * 0.45);
|
|
237
|
+
const d = 'M' + e.x1 + ',' + e.y1
|
|
238
|
+
+ ' C' + (e.x1 + bend) + ',' + e.y1
|
|
239
|
+
+ ' ' + (e.x2 - bend) + ',' + e.y2
|
|
240
|
+
+ ' ' + e.x2 + ',' + e.y2;
|
|
241
|
+
const on = related && related.has(e.from) && related.has(e.to);
|
|
242
|
+
return html`<path key=${e.from + '->' + e.to} d=${d}
|
|
243
|
+
class=${'pg-edge' + (on ? ' pg-related' : '')}
|
|
244
|
+
marker-end="url(#pg-arrow)"/>`;
|
|
245
|
+
})}
|
|
246
|
+
${phases.map(p => {
|
|
247
|
+
const id = String(p.id);
|
|
248
|
+
const { x, y } = model.pos.get(id);
|
|
249
|
+
const kind = statusKind(p.status);
|
|
250
|
+
const on = related && related.has(id);
|
|
251
|
+
return html`
|
|
252
|
+
<g key=${id}
|
|
253
|
+
class=${'pg-node pg-' + kind + (on ? ' pg-related' : '')}
|
|
254
|
+
onClick=${() => goToPhase(p.id)}
|
|
255
|
+
onMouseEnter=${() => setHoverId(id)}
|
|
256
|
+
onMouseLeave=${() => setHoverId(null)}>
|
|
257
|
+
<rect x=${x} y=${y} width=${NODE_W} height=${NODE_H} rx="8"/>
|
|
258
|
+
<text x=${x + 10} y=${y + 18} class="pg-label">P${p.id}</text>
|
|
259
|
+
<text x=${x + 10} y=${y + 33} class="pg-sublabel">${truncate(p.name, 21)}</text>
|
|
260
|
+
</g>
|
|
261
|
+
`;
|
|
262
|
+
})}
|
|
263
|
+
${hovered ? html`<${Tooltip} phase=${hovered}
|
|
264
|
+
nodePos=${model.pos.get(hoverId)}
|
|
265
|
+
canvasW=${model.width} canvasH=${model.height}/>` : null}
|
|
266
|
+
</svg>
|
|
267
|
+
</div>
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const LEGEND = [
|
|
272
|
+
['done', 'Done'], ['active', 'Active'], ['todo', 'Todo'], ['blocked', 'Blocked'],
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
export function PhaseGraph({ phases }) {
|
|
276
|
+
const list = Array.isArray(phases) ? phases : [];
|
|
277
|
+
const known = new Set(list.map(p => String(p.id)));
|
|
278
|
+
const hasDeps = list.some(p => resolvedDeps(p, known).length > 0);
|
|
279
|
+
|
|
280
|
+
return html`
|
|
281
|
+
<details class="pg-panel" open>
|
|
282
|
+
<summary>
|
|
283
|
+
<${Icon} name="layers" size=${14}/> Dependency Graph
|
|
284
|
+
<span class="pg-count">${list.length} ${list.length === 1 ? 'phase' : 'phases'}</span>
|
|
285
|
+
</summary>
|
|
286
|
+
<div class="pg-legend">
|
|
287
|
+
${LEGEND.map(([kind, label]) => html`
|
|
288
|
+
<span key=${kind} class="pg-legend-item">
|
|
289
|
+
<span class=${'pg-swatch pg-' + kind}></span>${label}
|
|
290
|
+
</span>
|
|
291
|
+
`)}
|
|
292
|
+
</div>
|
|
293
|
+
${!list.length
|
|
294
|
+
? html`<div class="pg-empty">No phases yet — plan a milestone with <code>/rcode-new-milestone</code> and the graph will appear here.</div>`
|
|
295
|
+
: hasDeps
|
|
296
|
+
? html`<${Dag} phases=${list}/>`
|
|
297
|
+
: html`<${FlowRow} phases=${list}/>`}
|
|
298
|
+
</details>
|
|
299
|
+
`;
|
|
300
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RejectDialog — structured rejection dialog for waiting checkpoint sessions.
|
|
3
|
+
*
|
|
4
|
+
* Props:
|
|
5
|
+
* session — OrchCard session object ({ storyId, phase?, ... })
|
|
6
|
+
* onClose — callback invoked on cancel, backdrop click, Escape, or after
|
|
7
|
+
* a successful submission.
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - Submit button is disabled until a non-empty reason is typed (GATE-1).
|
|
11
|
+
* - Uses showToast() for post-submit feedback; browser dialogs are forbidden.
|
|
12
|
+
* - All visuals are driven by CSS classes (no style attribute).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { html, useState, useEffect } from '../preact.js';
|
|
16
|
+
import { submitRejection } from '../orchestrator.js';
|
|
17
|
+
import { showToast } from './shared.js';
|
|
18
|
+
|
|
19
|
+
export function RejectDialog({ session, onClose }) {
|
|
20
|
+
const [reason, setReason] = useState('');
|
|
21
|
+
const [busy, setBusy] = useState(false);
|
|
22
|
+
|
|
23
|
+
const trimmed = reason.trim();
|
|
24
|
+
const disabled = !trimmed || busy;
|
|
25
|
+
|
|
26
|
+
// Escape-to-close
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
function handleKey(e) {
|
|
29
|
+
if (e.key === 'Escape') onClose();
|
|
30
|
+
}
|
|
31
|
+
document.addEventListener('keydown', handleKey);
|
|
32
|
+
return () => document.removeEventListener('keydown', handleKey);
|
|
33
|
+
}, [onClose]);
|
|
34
|
+
|
|
35
|
+
function handleSubmit() {
|
|
36
|
+
if (disabled) return;
|
|
37
|
+
setBusy(true);
|
|
38
|
+
submitRejection(session.storyId, trimmed, session.phase || null)
|
|
39
|
+
.then(d => {
|
|
40
|
+
if (d && d.ok) {
|
|
41
|
+
showToast('Rejection recorded');
|
|
42
|
+
onClose();
|
|
43
|
+
} else {
|
|
44
|
+
showToast('Reject failed: ' + ((d && d.error) || 'unknown'));
|
|
45
|
+
setBusy(false);
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.catch(() => {
|
|
49
|
+
showToast('Could not reach orchestrator');
|
|
50
|
+
setBusy(false);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return html`
|
|
55
|
+
<div class="reject-overlay" onClick=${onClose}>
|
|
56
|
+
<div class="reject-dialog" onClick=${e => e.stopPropagation()}>
|
|
57
|
+
<div class="reject-dialog-title">
|
|
58
|
+
Reject checkpoint — ${session.storyId}
|
|
59
|
+
</div>
|
|
60
|
+
<textarea
|
|
61
|
+
class="reject-dialog-input"
|
|
62
|
+
placeholder="Why is this checkpoint being rejected? (required)"
|
|
63
|
+
value=${reason}
|
|
64
|
+
onInput=${e => setReason(e.target.value)}
|
|
65
|
+
autofocus
|
|
66
|
+
></textarea>
|
|
67
|
+
<div class="reject-dialog-actions">
|
|
68
|
+
<button class="reject-cancel" onClick=${onClose}>Cancel</button>
|
|
69
|
+
<button
|
|
70
|
+
class="reject-submit"
|
|
71
|
+
disabled=${disabled}
|
|
72
|
+
onClick=${handleSubmit}
|
|
73
|
+
>${busy ? 'Recording…' : 'Submit rejection'}</button>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunnerPicker — anchored popover for choosing which agent CLI + model a
|
|
3
|
+
* Run button launches.
|
|
4
|
+
*
|
|
5
|
+
* One instance is mounted in App.js; every Run button opens it via
|
|
6
|
+
* openRunnerPicker(anchorEl, run). State lives in store.runnerPicker:
|
|
7
|
+
* { open, x, y, run: { kind: 'session'|'command', storyId?, cmd, title? } }
|
|
8
|
+
*
|
|
9
|
+
* kind 'session' → runAndOpenTerm(storyId, cmd, title, { runner, model })
|
|
10
|
+
* kind 'command' → runCommandFromUI(cmd, { runner, model })
|
|
11
|
+
*
|
|
12
|
+
* Runner list comes from GET /api/runners (fetchRunners, cached). Runners are
|
|
13
|
+
* rendered as an option list (not a <select>) so each row can carry a "Beta"
|
|
14
|
+
* pill (every CLI except claude) and unavailable ones can show their server-
|
|
15
|
+
* reported reason ('not installed' / 'untested flags') as a disabled tooltip.
|
|
16
|
+
* A runner with an empty models[] gets no model dropdown at all. The last
|
|
17
|
+
* confirmed runner + model are remembered in localStorage and preselected.
|
|
18
|
+
* Esc and click-outside close the popover. The server re-validates runner
|
|
19
|
+
* and model on POST /api/run — this UI is convenience, not the boundary.
|
|
20
|
+
*
|
|
21
|
+
* Positioning uses CSS custom properties (--rp-x/--rp-y) set via the element
|
|
22
|
+
* ref, never an inline style attribute; the popover clamps to the viewport.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { html, useState, useEffect, useRef } from '../preact.js';
|
|
26
|
+
import { useStore, setState } from '../store.js';
|
|
27
|
+
import { fetchRunners, runAndOpenTerm, runCommandFromUI } from '../orchestrator.js';
|
|
28
|
+
|
|
29
|
+
const PREF_KEY = 'majlis-runner-pref';
|
|
30
|
+
|
|
31
|
+
function loadPref() {
|
|
32
|
+
try { return JSON.parse(localStorage.getItem(PREF_KEY)) || {}; } catch { return {}; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function savePref(runner, model) {
|
|
36
|
+
try { localStorage.setItem(PREF_KEY, JSON.stringify({ runner, model })); } catch { /* private mode */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Open the picker anchored under anchorEl.
|
|
41
|
+
* @param {Element} anchorEl — the clicked Run button
|
|
42
|
+
* @param {{ kind: 'session'|'command', storyId?: string, cmd: string, title?: string }} run
|
|
43
|
+
*/
|
|
44
|
+
export function openRunnerPicker(anchorEl, run) {
|
|
45
|
+
const r = anchorEl && anchorEl.getBoundingClientRect
|
|
46
|
+
? anchorEl.getBoundingClientRect()
|
|
47
|
+
: { left: 24, bottom: 24 };
|
|
48
|
+
setState({
|
|
49
|
+
runnerPicker: { open: true, x: Math.round(r.left), y: Math.round(r.bottom + 6), run },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function closeRunnerPicker() {
|
|
54
|
+
setState({ runnerPicker: null });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Preselect the remembered runner/model when valid, else claude, else the first installed CLI. */
|
|
58
|
+
function initialSelection(runners) {
|
|
59
|
+
const pref = loadPref();
|
|
60
|
+
const valid = id => runners.some(r => r.id === id && r.available);
|
|
61
|
+
const runnerId = valid(pref.runner) ? pref.runner
|
|
62
|
+
: valid('claude') ? 'claude'
|
|
63
|
+
: (runners.find(r => r.available) || {}).id || '';
|
|
64
|
+
const entry = runners.find(r => r.id === runnerId);
|
|
65
|
+
const model = (entry && pref.runner === runnerId && entry.models.includes(pref.model))
|
|
66
|
+
? pref.model : '';
|
|
67
|
+
return { runnerId, model };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function RunnerPicker() {
|
|
71
|
+
const picker = useStore(s => s.runnerPicker);
|
|
72
|
+
const open = !!(picker && picker.open);
|
|
73
|
+
|
|
74
|
+
const [runners, setRunners ] = useState(null); // null = loading, [] = unreachable
|
|
75
|
+
const [runnerId, setRunnerId] = useState('');
|
|
76
|
+
const [model, setModel ] = useState('');
|
|
77
|
+
const ref = useRef(null);
|
|
78
|
+
|
|
79
|
+
// Load the runner list and (re)apply the remembered selection on each open.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!open) return;
|
|
82
|
+
let alive = true;
|
|
83
|
+
fetchRunners().then(list => {
|
|
84
|
+
if (!alive) return;
|
|
85
|
+
setRunners(list);
|
|
86
|
+
const sel = initialSelection(list);
|
|
87
|
+
setRunnerId(sel.runnerId);
|
|
88
|
+
setModel(sel.model);
|
|
89
|
+
});
|
|
90
|
+
return () => { alive = false; };
|
|
91
|
+
}, [open]);
|
|
92
|
+
|
|
93
|
+
// Esc / click-outside close. mousedown fires after the opening click's
|
|
94
|
+
// event cycle, so the click that opened the picker never closes it.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!open) return;
|
|
97
|
+
function onKey(e) { if (e.key === 'Escape') closeRunnerPicker(); }
|
|
98
|
+
function onDown(e) {
|
|
99
|
+
if (ref.current && !ref.current.contains(e.target)) closeRunnerPicker();
|
|
100
|
+
}
|
|
101
|
+
document.addEventListener('keydown', onKey);
|
|
102
|
+
document.addEventListener('mousedown', onDown);
|
|
103
|
+
return () => {
|
|
104
|
+
document.removeEventListener('keydown', onKey);
|
|
105
|
+
document.removeEventListener('mousedown', onDown);
|
|
106
|
+
};
|
|
107
|
+
}, [open]);
|
|
108
|
+
|
|
109
|
+
// Anchor below the Run button, clamped to the viewport. CSS vars (not an
|
|
110
|
+
// inline style attribute) carry the coordinates into the stylesheet.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!open || !ref.current) return;
|
|
113
|
+
const el = ref.current;
|
|
114
|
+
const x = Math.max(8, Math.min(picker.x, window.innerWidth - el.offsetWidth - 8));
|
|
115
|
+
const y = Math.max(8, Math.min(picker.y, window.innerHeight - el.offsetHeight - 8));
|
|
116
|
+
el.style.setProperty('--rp-x', x + 'px');
|
|
117
|
+
el.style.setProperty('--rp-y', y + 'px');
|
|
118
|
+
}, [open, picker, runners]);
|
|
119
|
+
|
|
120
|
+
if (!open) return null;
|
|
121
|
+
|
|
122
|
+
const entry = (runners || []).find(r => r.id === runnerId) || null;
|
|
123
|
+
const models = (entry && entry.models) || [];
|
|
124
|
+
|
|
125
|
+
function handleRunnerSelect(id) {
|
|
126
|
+
setRunnerId(id);
|
|
127
|
+
setModel(''); // model lists differ per runner — reset to CLI default
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleRun() {
|
|
131
|
+
const run = picker.run || {};
|
|
132
|
+
savePref(runnerId, model);
|
|
133
|
+
closeRunnerPicker();
|
|
134
|
+
const opts = { runner: runnerId, model };
|
|
135
|
+
if (run.kind === 'command') {
|
|
136
|
+
runCommandFromUI(run.cmd, opts);
|
|
137
|
+
} else {
|
|
138
|
+
runAndOpenTerm(run.storyId, run.cmd, run.title || run.storyId, opts);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return html`
|
|
143
|
+
<div class="runner-picker" ref=${ref} role="dialog" aria-label="Choose runner and model"
|
|
144
|
+
onClick=${e => e.stopPropagation()}>
|
|
145
|
+
<div class="runner-picker-title">
|
|
146
|
+
Run ${(picker.run && picker.run.title) || ''}
|
|
147
|
+
</div>
|
|
148
|
+
${runners === null ? html`
|
|
149
|
+
<div class="runner-picker-hint">Detecting installed CLIs…</div>
|
|
150
|
+
` : runners.length === 0 ? html`
|
|
151
|
+
<div class="runner-picker-hint">Orchestrator unreachable — cannot list runners.</div>
|
|
152
|
+
` : html`
|
|
153
|
+
<div class="runner-picker-field">
|
|
154
|
+
<span class="runner-picker-label" id="runner-picker-cli-label">Agent CLI</span>
|
|
155
|
+
<div class="runner-picker-list" role="listbox" aria-labelledby="runner-picker-cli-label">
|
|
156
|
+
${runners.map(r => html`
|
|
157
|
+
<button key=${r.id} type="button" role="option"
|
|
158
|
+
aria-selected=${r.id === runnerId}
|
|
159
|
+
class=${'runner-picker-option' + (r.id === runnerId ? ' selected' : '')}
|
|
160
|
+
disabled=${!r.available}
|
|
161
|
+
title=${r.available ? r.label : (r.reason || 'not installed')}
|
|
162
|
+
onClick=${() => handleRunnerSelect(r.id)}>
|
|
163
|
+
<span class="runner-picker-option-label">${r.label}</span>
|
|
164
|
+
${r.beta ? html`<span class="runner-beta-pill">Beta</span>` : null}
|
|
165
|
+
${!r.available ? html`
|
|
166
|
+
<span class="runner-picker-option-hint">${r.reason || 'not installed'}</span>
|
|
167
|
+
` : null}
|
|
168
|
+
</button>
|
|
169
|
+
`)}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
${models.length ? html`
|
|
173
|
+
<label class="runner-picker-field">
|
|
174
|
+
<span class="runner-picker-label">Model</span>
|
|
175
|
+
<select class="runner-picker-select" value=${model}
|
|
176
|
+
onChange=${e => setModel(e.target.value)}>
|
|
177
|
+
<option value="">default</option>
|
|
178
|
+
${models.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
|
|
179
|
+
</select>
|
|
180
|
+
</label>
|
|
181
|
+
` : null}
|
|
182
|
+
`}
|
|
183
|
+
<div class="runner-picker-actions">
|
|
184
|
+
<button class="runner-picker-btn" onClick=${closeRunnerPicker}>Cancel</button>
|
|
185
|
+
<button class="runner-picker-btn runner-picker-btn--run"
|
|
186
|
+
disabled=${!runnerId} onClick=${handleRun}>▶ Run</button>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
`;
|
|
190
|
+
}
|