@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.
Files changed (67) hide show
  1. package/cli/install.js +176 -13
  2. package/cli/lib/config.cjs +4 -2
  3. package/cli/lib/fsutil.cjs +13 -2
  4. package/cli/lib/homedir.cjs +21 -0
  5. package/cli/lib/schemas.cjs +6 -1
  6. package/cli/nuke.js +13 -8
  7. package/cli/postinstall.js +14 -4
  8. package/cli/rcode-slash-router.cjs +118 -0
  9. package/cli/uninstall.js +59 -1
  10. package/cli/update.js +10 -5
  11. package/dist/rcode.js +234 -230
  12. package/package.json +1 -1
  13. package/server/dashboard.js +26 -7
  14. package/server/lib/api.js +62 -4
  15. package/server/lib/html/client/agents-data.js +22 -18
  16. package/server/lib/html/client/app.js +3 -0
  17. package/server/lib/html/client/components/AgentCard.js +127 -0
  18. package/server/lib/html/client/components/App.js +104 -39
  19. package/server/lib/html/client/components/CommandPalette.js +133 -0
  20. package/server/lib/html/client/components/FileReader.js +116 -0
  21. package/server/lib/html/client/components/FilterChips.js +94 -0
  22. package/server/lib/html/client/components/NotifyCenter.js +117 -0
  23. package/server/lib/html/client/components/OrchPanel.js +80 -52
  24. package/server/lib/html/client/components/PhaseGraph.js +300 -0
  25. package/server/lib/html/client/components/RejectDialog.js +78 -0
  26. package/server/lib/html/client/components/RunnerPicker.js +190 -0
  27. package/server/lib/html/client/components/Sidebar.js +106 -61
  28. package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
  29. package/server/lib/html/client/components/TaskPipeline.js +83 -0
  30. package/server/lib/html/client/components/Topbar.js +86 -39
  31. package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
  32. package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
  33. package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
  34. package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
  35. package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
  36. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
  37. package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
  38. package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
  39. package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
  40. package/server/lib/html/client/components/shared.js +47 -11
  41. package/server/lib/html/client/filter-state.js +72 -0
  42. package/server/lib/html/client/icons-client.js +7 -0
  43. package/server/lib/html/client/notify.js +75 -0
  44. package/server/lib/html/client/orchestrator.js +168 -41
  45. package/server/lib/html/client/preact.js +13 -8
  46. package/server/lib/html/client/store.js +70 -6
  47. package/server/lib/html/client/util.js +78 -0
  48. package/server/lib/html/client/vendor/htm.js +1 -0
  49. package/server/lib/html/client/vendor/preact-hooks.js +2 -0
  50. package/server/lib/html/client/vendor/preact.js +2 -0
  51. package/server/lib/html/client/views/AgentsView.js +144 -51
  52. package/server/lib/html/client/views/FilesView.js +20 -103
  53. package/server/lib/html/client/views/KanbanView.js +40 -21
  54. package/server/lib/html/client/views/MemoryView.js +26 -9
  55. package/server/lib/html/client/views/MilestonesView.js +4 -4
  56. package/server/lib/html/client/views/OrchestrationView.js +154 -19
  57. package/server/lib/html/client/views/OverviewView.js +47 -239
  58. package/server/lib/html/client/views/PhasesView.js +50 -6
  59. package/server/lib/html/client/views/RoadmapView.js +6 -3
  60. package/server/lib/html/client/views/SprintsView.js +50 -6
  61. package/server/lib/html/client/views/TasksView.js +4 -3
  62. package/server/lib/html/client.js +21 -4
  63. package/server/lib/html/css.js +2761 -8
  64. package/server/lib/html/icons.js +7 -0
  65. package/server/lib/html/shell.js +10 -3
  66. package/server/lib/scanner.js +376 -39
  67. 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
+ }