@hanzlaa/rcode 4.5.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -14
- package/package.json +1 -1
- package/rcode/agents/rcode-mariam.md +6 -0
- package/rcode/agents/rcode-sadiq.md +6 -0
- package/rcode/agents/rcode-waleed.md +6 -0
- package/rcode/bin/rcode-hooks.cjs +41 -5
- package/rcode/skills/agents/mariam-marketing/SKILL.md +1 -0
- package/rcode/skills/agents/sadiq-analyst/SKILL.md +1 -0
- package/rcode/skills/agents/waleed-architect/SKILL.md +1 -0
- package/rcode/workflows/council.md +29 -1
- package/server/dashboard.js +6 -3
- package/server/lib/html/client/components/App.js +16 -2
- package/server/lib/html/client/components/OrchPanel.js +2 -2
- package/server/lib/html/client/components/PhaseGraph.js +20 -12
- package/server/lib/html/client/components/Sidebar.js +1 -0
- package/server/lib/html/client/components/Topbar.js +3 -3
- package/server/lib/html/client/components/XtermPanel.js +98 -23
- package/server/lib/html/client/components/dashboard/Blockers.js +3 -3
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +4 -2
- package/server/lib/html/client/components/dashboard/InProgress.js +4 -3
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +11 -6
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +4 -3
- package/server/lib/html/client/components/shared.js +111 -2
- package/server/lib/html/client/orchestrator.js +28 -15
- package/server/lib/html/client/store.js +47 -0
- package/server/lib/html/client/util.js +1 -22
- package/server/lib/html/client/views/BacklogView.js +44 -0
- package/server/lib/html/client/views/DecisionsView.js +4 -3
- package/server/lib/html/client/views/FilesView.js +47 -34
- package/server/lib/html/client/views/KanbanView.js +8 -0
- package/server/lib/html/client/views/OrchestrationView.js +247 -169
- package/server/lib/html/client/views/PhasesView.js +8 -3
- package/server/lib/html/client/views/SprintsView.js +9 -0
- package/server/lib/html/client.js +1 -0
- package/server/lib/html/css.js +692 -246
- package/server/lib/html/shell.js +9 -3
- package/server/lib/scanner.js +21 -6
- package/server/orchestrator.js +3 -4
|
@@ -9,18 +9,34 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Store field: state.terminal = { open, storyId, title, minimized, fullscreen }
|
|
11
11
|
* Setting state.terminal via orchestrator.js triggers this component.
|
|
12
|
+
*
|
|
13
|
+
* Two mount points, one singleton terminal:
|
|
14
|
+
* - App.js mounts one instance as a floating overlay (backdrop + sliding
|
|
15
|
+
* panel + minimized pill) on every view.
|
|
16
|
+
* - OrchestrationView.js mounts a second instance with `docked=true` to
|
|
17
|
+
* embed the SAME xterm.js Terminal inline in its right column.
|
|
18
|
+
* Only one instance may touch the DOM at a time — App.js passes
|
|
19
|
+
* `suspend=${view === 'orchestration'}` so its overlay instance goes fully
|
|
20
|
+
* inert (renders null, effects no-op) while Orchestration's docked instance
|
|
21
|
+
* is mounted. `ensureTerm()` reparents the shared xterm DOM node into
|
|
22
|
+
* whichever container asks for it, so the buffer/connection survive the
|
|
23
|
+
* hand-off in both directions.
|
|
12
24
|
*/
|
|
13
25
|
|
|
14
26
|
import { html, useEffect, useRef, useCallback } from '../preact.js';
|
|
15
27
|
import { useStore, setState } from '../store.js';
|
|
16
|
-
import { orchToken, stopSession,
|
|
28
|
+
import { orchToken, stopSession, orchWs } from '../orchestrator.js';
|
|
17
29
|
|
|
18
30
|
// ── Internal state (module-scoped, one panel at a time) ──────────────────────
|
|
19
|
-
// These
|
|
20
|
-
// across panel open/close cycles
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
// These are NOT component state because the xterm instance (and the story it
|
|
32
|
+
// is currently connected to) must persist across panel open/close cycles,
|
|
33
|
+
// Preact re-renders, and — now — across the two XtermPanel mount points
|
|
34
|
+
// (floating overlay vs. docked). Component-local refs would not be shared
|
|
35
|
+
// between those two instances.
|
|
36
|
+
let _term = null;
|
|
37
|
+
let _termFit = null;
|
|
38
|
+
let _termWs = null;
|
|
39
|
+
let _currentStory = null;
|
|
24
40
|
|
|
25
41
|
function setStatus(dotStatus) {
|
|
26
42
|
// Propagate connection status via a store signal so the pill/header can react
|
|
@@ -34,9 +50,23 @@ function _resize() {
|
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
|
|
37
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Build the xterm instance exactly once; attach to `containerEl`.
|
|
55
|
+
* If the instance already exists but lives under a DIFFERENT container
|
|
56
|
+
* (e.g. the overlay panel had it, and the docked panel is now asking), move
|
|
57
|
+
* its root DOM node into `containerEl` instead of no-oping. xterm.js's root
|
|
58
|
+
* element is a plain DOM node — reparenting it is safe and preserves the
|
|
59
|
+
* scrollback buffer and any live WebSocket connection.
|
|
60
|
+
*/
|
|
38
61
|
function ensureTerm(containerEl) {
|
|
39
|
-
if (_term
|
|
62
|
+
if (_term) {
|
|
63
|
+
if (_term.element && _term.element.parentElement !== containerEl) {
|
|
64
|
+
containerEl.appendChild(_term.element);
|
|
65
|
+
if (_termFit) { try { _termFit.fit(); } catch (_e) {} }
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (typeof Terminal === 'undefined') return;
|
|
40
70
|
_term = new Terminal({
|
|
41
71
|
theme: {
|
|
42
72
|
background: '#0c0c0e', foreground: '#c9d1d9',
|
|
@@ -74,7 +104,7 @@ function connectWs(storyId) {
|
|
|
74
104
|
return;
|
|
75
105
|
}
|
|
76
106
|
setStatus('connecting');
|
|
77
|
-
const url =
|
|
107
|
+
const url = orchWs() + '/ws/' + encodeURIComponent(storyId) + '?token=' + encodeURIComponent(tok);
|
|
78
108
|
const ws = new WebSocket(url);
|
|
79
109
|
_termWs = ws;
|
|
80
110
|
|
|
@@ -98,10 +128,9 @@ function connectWs(storyId) {
|
|
|
98
128
|
|
|
99
129
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
100
130
|
|
|
101
|
-
export function XtermPanel() {
|
|
131
|
+
export function XtermPanel({ docked = false, suspend = false } = {}) {
|
|
102
132
|
const { terminal, termStatus } = useStore();
|
|
103
133
|
const containerRef = useRef(null);
|
|
104
|
-
const currentStoryRef = useRef(null);
|
|
105
134
|
|
|
106
135
|
const t = terminal || {};
|
|
107
136
|
const open = !!t.open;
|
|
@@ -110,28 +139,37 @@ export function XtermPanel() {
|
|
|
110
139
|
const storyId = t.storyId || '';
|
|
111
140
|
const title = t.title || 'Terminal';
|
|
112
141
|
|
|
113
|
-
// Build xterm instance on
|
|
114
|
-
//
|
|
115
|
-
//
|
|
142
|
+
// Build/attach the xterm instance on open; (re)connect only when the
|
|
143
|
+
// focused storyId actually changes. `_currentStory` is module-scoped (not
|
|
144
|
+
// a per-instance ref) so that handing the terminal off between the
|
|
145
|
+
// floating overlay and the docked panel — same storyId, different
|
|
146
|
+
// container — reparents via ensureTerm() without tearing down the
|
|
147
|
+
// connection or clearing the buffer. `suspend` is in the dep array so the
|
|
148
|
+
// OTHER (un-suspending) instance re-runs this effect and reclaims the
|
|
149
|
+
// terminal DOM node when the user navigates away from Orchestration.
|
|
116
150
|
useEffect(() => {
|
|
117
|
-
if (!open || !containerRef.current) return;
|
|
151
|
+
if (suspend || !open || !containerRef.current) return;
|
|
118
152
|
ensureTerm(containerRef.current);
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
153
|
+
const isNewSession = storyId && storyId !== _currentStory;
|
|
154
|
+
if (isNewSession) {
|
|
155
|
+
_currentStory = storyId;
|
|
156
|
+
if (_term) _term.clear();
|
|
122
157
|
connectWs(storyId);
|
|
123
158
|
}
|
|
159
|
+
_resize();
|
|
124
160
|
window.addEventListener('resize', _resize);
|
|
125
161
|
return () => window.removeEventListener('resize', _resize);
|
|
126
|
-
}, [open, storyId]);
|
|
162
|
+
}, [open, storyId, suspend]);
|
|
127
163
|
|
|
128
164
|
// Resize when entering/leaving fullscreen or on open
|
|
129
165
|
useEffect(() => {
|
|
130
|
-
if (open) { setTimeout(_resize, 50); }
|
|
131
|
-
}, [open, fullscreen]);
|
|
166
|
+
if (!suspend && open) { setTimeout(_resize, 50); }
|
|
167
|
+
}, [open, fullscreen, suspend]);
|
|
132
168
|
|
|
133
|
-
// Escape key closes
|
|
169
|
+
// Escape key closes (docked panel has no "close" concept — it just shows
|
|
170
|
+
// the empty state when store.terminal is cleared elsewhere)
|
|
134
171
|
useEffect(() => {
|
|
172
|
+
if (suspend || docked) return;
|
|
135
173
|
function onKey(e) {
|
|
136
174
|
if (e.key === 'Escape' && open && !minimized) {
|
|
137
175
|
setState({ terminal: { ...t, open: false } });
|
|
@@ -139,9 +177,11 @@ export function XtermPanel() {
|
|
|
139
177
|
}
|
|
140
178
|
window.addEventListener('keydown', onKey);
|
|
141
179
|
return () => window.removeEventListener('keydown', onKey);
|
|
142
|
-
}, [open, minimized, t]);
|
|
180
|
+
}, [open, minimized, t, suspend, docked]);
|
|
143
181
|
|
|
144
182
|
const dotCls = 'term-status-dot ' + (termStatus || '');
|
|
183
|
+
// Statuses that mean "output is actively streaming" for the docked live pulse.
|
|
184
|
+
const isLive = open && ['running', 'connecting', 'blocked', 'waiting'].includes(termStatus);
|
|
145
185
|
|
|
146
186
|
// ── Actions ──
|
|
147
187
|
const handleMinimize = useCallback(() => {
|
|
@@ -167,6 +207,41 @@ export function XtermPanel() {
|
|
|
167
207
|
setTimeout(_resize, 50);
|
|
168
208
|
}, [t, fullscreen]);
|
|
169
209
|
|
|
210
|
+
// Fully inert while the sibling instance owns the terminal DOM — no
|
|
211
|
+
// backdrop, no panel, no pill, nothing rendered at all.
|
|
212
|
+
if (suspend) return null;
|
|
213
|
+
|
|
214
|
+
// ── Docked render (Orchestration view's right column) ──
|
|
215
|
+
if (docked) {
|
|
216
|
+
return html`
|
|
217
|
+
<div class="orch-term-dock">
|
|
218
|
+
<div class="orch-term-dock-header">
|
|
219
|
+
<span class="orch-term-dot red"></span>
|
|
220
|
+
<span class="orch-term-dot amber"></span>
|
|
221
|
+
<span class="orch-term-dot green"></span>
|
|
222
|
+
<span class="orch-term-dock-label">xterm${open ? ' · ' + title : ''}</span>
|
|
223
|
+
${isLive ? html`
|
|
224
|
+
<span class="orch-term-dock-live">
|
|
225
|
+
<span class="orch-term-dock-live-dot"></span>live
|
|
226
|
+
</span>
|
|
227
|
+
` : null}
|
|
228
|
+
${open ? html`
|
|
229
|
+
<button class="orch-term-dock-stop" onClick=${handleStop} title="End the agent session">Stop</button>
|
|
230
|
+
` : null}
|
|
231
|
+
</div>
|
|
232
|
+
<div class="orch-term-dock-body">
|
|
233
|
+
${open
|
|
234
|
+
? html`<div ref=${containerRef} class="orch-term-dock-container"></div>`
|
|
235
|
+
: html`
|
|
236
|
+
<div class="orch-term-dock-empty">
|
|
237
|
+
No active execution. Select a command from the Runner picker to begin.
|
|
238
|
+
</div>
|
|
239
|
+
`}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
`;
|
|
243
|
+
}
|
|
244
|
+
|
|
170
245
|
// ── Pill (minimized state) ──
|
|
171
246
|
const pill = html`
|
|
172
247
|
<div
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { html } from '../../preact.js';
|
|
14
|
-
import { useStore } from '../../store.js';
|
|
15
|
-
import {
|
|
14
|
+
import { useStore, openBlockerViewer } from '../../store.js';
|
|
15
|
+
import { pressable } from '../shared.js';
|
|
16
16
|
|
|
17
17
|
// Severity → pill label (lowercase enum to human-facing label).
|
|
18
18
|
const SEV_LABEL = { high: 'High', medium: 'Medium', low: 'Low' };
|
|
@@ -40,7 +40,7 @@ export function Blockers() {
|
|
|
40
40
|
${blockers.map((b) => {
|
|
41
41
|
const sev = SEV_LABEL[b.severity] ? b.severity : 'low';
|
|
42
42
|
return html`
|
|
43
|
-
<li class="bk-row ovr-link" key=${b.title} ...${
|
|
43
|
+
<li class="bk-row ovr-link" key=${b.title} ...${pressable(() => openBlockerViewer(b))}>
|
|
44
44
|
<span class=${'bk-icon bk-sev-' + sev} aria-hidden="true">⚠</span>
|
|
45
45
|
<div class="bk-body">
|
|
46
46
|
<p class="bk-title">${b.title}</p>
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { html } from '../../preact.js';
|
|
13
|
-
import { useStore } from '../../store.js';
|
|
13
|
+
import { useStore, openFileViewer } from '../../store.js';
|
|
14
14
|
import { humanDate } from '../../util.js';
|
|
15
|
+
import { pressable } from '../shared.js';
|
|
15
16
|
|
|
16
17
|
export function CompletedTasks() {
|
|
17
18
|
const S = useStore();
|
|
@@ -30,7 +31,8 @@ export function CompletedTasks() {
|
|
|
30
31
|
: html`
|
|
31
32
|
<ul class="ct-list">
|
|
32
33
|
${items.map((t, i) => html`
|
|
33
|
-
<li class
|
|
34
|
+
<li class=${'ct-row' + (t.file ? ' ovr-link' : '')} key=${t.title + i}
|
|
35
|
+
...${t.file ? pressable(() => openFileViewer(t.file, t.title)) : {}}>
|
|
34
36
|
<svg class="ct-check" width="16" height="16" viewBox="0 0 24 24"
|
|
35
37
|
fill="none" stroke="currentColor" stroke-width="2.5"
|
|
36
38
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { html } from '../../preact.js';
|
|
21
|
-
import { useStore } from '../../store.js';
|
|
22
|
-
import { orchElapsed
|
|
21
|
+
import { useStore, openFileViewer } from '../../store.js';
|
|
22
|
+
import { orchElapsed } from '../../util.js';
|
|
23
23
|
import { openTermPanel } from '../../orchestrator.js';
|
|
24
24
|
import { pressable } from '../shared.js';
|
|
25
25
|
import { TaskPipeline } from '../TaskPipeline.js';
|
|
@@ -60,7 +60,8 @@ export function InProgress() {
|
|
|
60
60
|
<ul class="ip-list">
|
|
61
61
|
${live.map(s => html`<${LiveRow} key=${'live-' + s.storyId} session=${s}/>`)}
|
|
62
62
|
${items.map((t, i) => html`
|
|
63
|
-
<li class
|
|
63
|
+
<li class=${'ip-row' + (t.file ? ' ovr-link' : '')} key=${t.title + i}
|
|
64
|
+
...${t.file ? pressable(() => openFileViewer(t.file, t.title)) : {}}>
|
|
64
65
|
<span class="ip-title">${t.title}</span>
|
|
65
66
|
<${TaskPipeline} task=${t} mini=${true}/>
|
|
66
67
|
${Number.isFinite(t.pct) ? html`<span class="ip-badge">${t.pct}%</span>` : null}
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { html } from '../../preact.js';
|
|
15
|
-
import { useStore } from '../../store.js';
|
|
16
|
-
import {
|
|
15
|
+
import { useStore, openFileViewer } from '../../store.js';
|
|
16
|
+
import { pressable } from '../shared.js';
|
|
17
17
|
|
|
18
18
|
// Map phase state → label + badge/segment modifier.
|
|
19
19
|
function stateMeta(state) {
|
|
@@ -84,11 +84,16 @@ export function ProgressTimeline() {
|
|
|
84
84
|
<div class="pt-track">
|
|
85
85
|
${phases.map((p, i) => {
|
|
86
86
|
const m = stateMeta(p.state);
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
87
|
+
// Same "first sprint with a resolved file" lookup PhasesView uses
|
|
88
|
+
// for its "View plan file" button. Falls back to the phase detail
|
|
89
|
+
// page when nothing resolved (e.g. phase has no SPRINT.md yet).
|
|
90
|
+
const sps = Array.isArray(p.sprints) ? p.sprints : [];
|
|
91
|
+
const planFile = (sps.find(s => s.file) || {}).file || null;
|
|
92
|
+
const onActivate = planFile
|
|
93
|
+
? () => openFileViewer(planFile, 'Phase ' + (p.id != null ? p.id : '') + ' plan')
|
|
94
|
+
: () => { location.hash = p.id != null ? 'phases/' + p.id : 'phases'; };
|
|
90
95
|
return html`
|
|
91
|
-
<div class=${'pt-seg ovr-link ' + m.mod} key=${p.name + i} ...${
|
|
96
|
+
<div class=${'pt-seg ovr-link ' + m.mod} key=${p.name + i} ...${pressable(onActivate)}>
|
|
92
97
|
<span class="pt-seg-name">${p.name}</span>
|
|
93
98
|
<span class="pt-seg-range">${p.range || ''}</span>
|
|
94
99
|
<span class="pt-seg-badge">${m.label}</span>
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { html } from '../../preact.js';
|
|
12
|
-
import { useStore } from '../../store.js';
|
|
13
|
-
import { humanDate
|
|
12
|
+
import { useStore, openDecisionViewer } from '../../store.js';
|
|
13
|
+
import { humanDate } from '../../util.js';
|
|
14
|
+
import { pressable } from '../shared.js';
|
|
14
15
|
|
|
15
16
|
// Map a free-form status string to a badge modifier class.
|
|
16
17
|
function statusClass(status) {
|
|
@@ -42,7 +43,7 @@ export function RecentDecisions() {
|
|
|
42
43
|
: html`
|
|
43
44
|
<ul class="rd-list">
|
|
44
45
|
${decisions.map((d, i) => html`
|
|
45
|
-
<li class="rd-row ovr-link" key=${d.title + i} ...${
|
|
46
|
+
<li class="rd-row ovr-link" key=${d.title + i} ...${pressable(() => openDecisionViewer(d))}>
|
|
46
47
|
<span class="rd-title">${d.title}</span>
|
|
47
48
|
${d.status
|
|
48
49
|
? html`<span class=${'rd-badge ' + statusClass(d.status)}>${d.status}</span>`
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* Import from here; do NOT inline these in view modules.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { html, useState } from '../preact.js';
|
|
10
|
+
import { html, useState, useEffect } from '../preact.js';
|
|
11
11
|
import { pctNum, chip as chipDesc, humanDate, pct, currentPhaseId } from '../util.js';
|
|
12
12
|
import {
|
|
13
13
|
isSessionRunning, runningInSprint, runningInPhase,
|
|
14
14
|
} from '../orchestrator.js';
|
|
15
|
-
import { getState } from '../store.js';
|
|
15
|
+
import { getState, openFileViewer } from '../store.js';
|
|
16
16
|
import { Icon } from '../icons-client.js';
|
|
17
17
|
import { TaskPipeline } from './TaskPipeline.js';
|
|
18
18
|
import { openRunnerPicker } from './RunnerPicker.js';
|
|
@@ -264,11 +264,20 @@ export function SprintCard({ sprint: s, S }) {
|
|
|
264
264
|
const borderStyle = isCur
|
|
265
265
|
? 'border-left-color:var(--accent-amber);background:rgba(245,158,11,0.04)'
|
|
266
266
|
: '';
|
|
267
|
+
function handleViewFile(e) {
|
|
268
|
+
e.stopPropagation();
|
|
269
|
+
openFileViewer(s.file, 'Sprint ' + s.id);
|
|
270
|
+
}
|
|
267
271
|
return html`
|
|
268
272
|
<div class=${'item item-clickable' + (isCur ? ' sprint-current' : '')} style=${borderStyle}
|
|
269
273
|
...${pressable(() => { location.hash = 'sprints/' + s.id; })}>
|
|
270
274
|
<div class="item-title">
|
|
271
275
|
<${RunBtn} storyId=${'sprint-' + s.id} cmd=${'/rcode-execute-sprint ' + s.id} label=${'Sprint ' + s.id}/>
|
|
276
|
+
${s.file ? html`
|
|
277
|
+
<button class="card-file-btn" title=${'View ' + s.file} onClick=${handleViewFile}>
|
|
278
|
+
<${Icon} name="file-text" size=${11}/> File
|
|
279
|
+
</button>
|
|
280
|
+
` : null}
|
|
272
281
|
Sprint ${s.id} — ${s.goal || 'No goal'}
|
|
273
282
|
${isCur ? html`<${Tag}>current</${Tag}>` : null}
|
|
274
283
|
<${Chip} status=${s.status}/>
|
|
@@ -321,6 +330,10 @@ export function TaskCard({ task: t }) {
|
|
|
321
330
|
}
|
|
322
331
|
}
|
|
323
332
|
|
|
333
|
+
function handleViewFile(e) {
|
|
334
|
+
e.stopPropagation();
|
|
335
|
+
openFileViewer(t.file, t.title);
|
|
336
|
+
}
|
|
324
337
|
return html`
|
|
325
338
|
<div class="item item-clickable" data-status=${t.status || ''}
|
|
326
339
|
style=${done ? 'opacity:.65' : ''}
|
|
@@ -328,6 +341,11 @@ export function TaskCard({ task: t }) {
|
|
|
328
341
|
...${pressable(() => setExpanded(e => !e))}>
|
|
329
342
|
<div class="item-title" style=${done ? 'text-decoration:line-through' : ''}>
|
|
330
343
|
${t.id && !done ? html`<${RunBtn} storyId=${t.id} cmd=${'/rcode-dev-story ' + t.id} label=${'Story ' + t.id}/>` : null}
|
|
344
|
+
${t.file ? html`
|
|
345
|
+
<button class="card-file-btn" title=${'View ' + t.file} onClick=${handleViewFile}>
|
|
346
|
+
<${Icon} name="file-text" size=${11}/> File
|
|
347
|
+
</button>
|
|
348
|
+
` : null}
|
|
331
349
|
${done ? '✓ ' : ''}${t.title}
|
|
332
350
|
<${Chip} status=${t.status}/>
|
|
333
351
|
<span class="task-expand-icon">${expanded ? '▼' : '▶'}</span>
|
|
@@ -378,3 +396,94 @@ export function TaskCard({ task: t }) {
|
|
|
378
396
|
</div>
|
|
379
397
|
`;
|
|
380
398
|
}
|
|
399
|
+
|
|
400
|
+
// ---- DecisionDrawer ----
|
|
401
|
+
/**
|
|
402
|
+
* Same slide-over shell as FileReader (.reader-*) but shows a decision
|
|
403
|
+
* record's own fields instead of fetching a file — decisions have no
|
|
404
|
+
* backing markdown file, only whatever state.json recorded for them.
|
|
405
|
+
* @param {{ decision: object|null, onClose: function }} props
|
|
406
|
+
*/
|
|
407
|
+
export function DecisionDrawer({ decision: d, onClose }) {
|
|
408
|
+
// Hooks run unconditionally (Rules of Hooks) even though the component
|
|
409
|
+
// renders null below when there's nothing open.
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
function onKey(e) { if (e.key === 'Escape' && onClose) onClose(); }
|
|
412
|
+
document.addEventListener('keydown', onKey);
|
|
413
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
414
|
+
}, [onClose]);
|
|
415
|
+
|
|
416
|
+
if (!d) return null;
|
|
417
|
+
const title = d.title || d.summary || d.decision || 'Decision';
|
|
418
|
+
|
|
419
|
+
// state.json only records a short summary + a few fields for each entry —
|
|
420
|
+
// the full write-up (rationale, alternatives considered) lives in the
|
|
421
|
+
// separate, manually-curated decision log. There's no reliable way to
|
|
422
|
+
// match this specific record to one entry in that file, so this opens the
|
|
423
|
+
// whole log rather than pretending to jump to the right spot.
|
|
424
|
+
function handleOpenLog(e) {
|
|
425
|
+
e.stopPropagation();
|
|
426
|
+
onClose();
|
|
427
|
+
openFileViewer('.rcode/memory/project/decisions.md', 'Decision Log');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return html`
|
|
431
|
+
<div class="reader-backdrop" onClick=${onClose}></div>
|
|
432
|
+
<div class="reader-panel" role="dialog" aria-label=${title}>
|
|
433
|
+
<div class="reader-header">
|
|
434
|
+
<div class="reader-heading">
|
|
435
|
+
<div class="reader-title">${title}</div>
|
|
436
|
+
${d.phase ? html`<div class="reader-path">Phase ${d.phase}</div>` : null}
|
|
437
|
+
</div>
|
|
438
|
+
<div class="reader-actions">
|
|
439
|
+
<button class="reader-copy" onClick=${handleOpenLog}>View decision log →</button>
|
|
440
|
+
<button class="reader-close" aria-label="Close" onClick=${onClose}>×</button>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
<div class="reader-body">
|
|
444
|
+
${d.status ? html`<div class="task-detail-row"><strong>Status:</strong> <${Chip} status=${d.status}/></div>` : null}
|
|
445
|
+
${d.date ? html`<div class="task-detail-row"><strong>Date:</strong> ${humanDate(d.date)}</div>` : null}
|
|
446
|
+
${d.plan != null ? html`<div class="task-detail-row"><strong>Plan:</strong> ${d.plan}</div>` : null}
|
|
447
|
+
${d.rationale ? html`<div class="task-detail-row"><strong>Rationale:</strong> ${d.rationale}</div>` : null}
|
|
448
|
+
<div class="dash-empty">
|
|
449
|
+
<span>This is everything state.json recorded for this entry. Full rationale and alternatives (when written up) live in the decision log.</span>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---- BlockerDrawer ----
|
|
457
|
+
/**
|
|
458
|
+
* Same slide-over shell as DecisionDrawer — blockers are plain
|
|
459
|
+
* { title, desc, severity } records in state.json, no backing file.
|
|
460
|
+
* @param {{ blocker: object|null, onClose: function }} props
|
|
461
|
+
*/
|
|
462
|
+
export function BlockerDrawer({ blocker: b, onClose }) {
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
function onKey(e) { if (e.key === 'Escape' && onClose) onClose(); }
|
|
465
|
+
document.addEventListener('keydown', onKey);
|
|
466
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
467
|
+
}, [onClose]);
|
|
468
|
+
|
|
469
|
+
if (!b) return null;
|
|
470
|
+
const sev = ['high', 'medium', 'low'].includes(b.severity) ? b.severity : 'low';
|
|
471
|
+
|
|
472
|
+
return html`
|
|
473
|
+
<div class="reader-backdrop" onClick=${onClose}></div>
|
|
474
|
+
<div class="reader-panel" role="dialog" aria-label=${b.title}>
|
|
475
|
+
<div class="reader-header">
|
|
476
|
+
<div class="reader-heading">
|
|
477
|
+
<div class="reader-title">${b.title}</div>
|
|
478
|
+
</div>
|
|
479
|
+
<div class="reader-actions">
|
|
480
|
+
<button class="reader-close" aria-label="Close" onClick=${onClose}>×</button>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
<div class="reader-body">
|
|
484
|
+
<div class="task-detail-row"><strong>Severity:</strong> <span class=${'bk-pill bk-sev-' + sev}>${sev}</span></div>
|
|
485
|
+
${b.desc ? html`<div class="task-detail-row"><strong>Details:</strong> ${b.desc}</div>` : null}
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
`;
|
|
489
|
+
}
|
|
@@ -5,17 +5,27 @@
|
|
|
5
5
|
* Preact store (activeSessions field). Components import these functions
|
|
6
6
|
* directly; no window.* globals needed after Sprint 31.4.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Functions
|
|
9
|
+
* orchHttp() — base URL for orchestrator REST API
|
|
10
|
+
* orchWs() — base URL for orchestrator WebSocket
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { getState, setState } from './store.js';
|
|
14
14
|
import { showToast } from './components/shared.js';
|
|
15
15
|
import { trackBlocked } from './notify.js';
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// #969 — the orchestrator port is injected by the server (see shell.js) as
|
|
18
|
+
// window.__ORCH_PORT__, since a dashboard started with ORCH_PORT set (e.g. a
|
|
19
|
+
// second instance under test) spawns its orchestrator on a non-default port.
|
|
20
|
+
// A hardcoded 7718 here would silently drive the wrong orchestrator process.
|
|
21
|
+
// Resolved per-call (not cached at module load) so it works even if a caller
|
|
22
|
+
// loads this module before the inline bootstrap script has run.
|
|
23
|
+
function orchPort() {
|
|
24
|
+
return (typeof window !== 'undefined' && window.__ORCH_PORT__) || 7718;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function orchHttp() { return 'http://localhost:' + orchPort(); }
|
|
28
|
+
export function orchWs() { return 'ws://localhost:' + orchPort(); }
|
|
19
29
|
|
|
20
30
|
// ── Token helpers ─────────────────────────────────────────────────────────────
|
|
21
31
|
|
|
@@ -31,7 +41,10 @@ export function orchToken() {
|
|
|
31
41
|
export function refreshOrchToken() {
|
|
32
42
|
return fetch('/api/orch-token')
|
|
33
43
|
.then(r => r.json())
|
|
34
|
-
.then(d => {
|
|
44
|
+
.then(d => {
|
|
45
|
+
if (d && d.token) window.__ORCH_TOKEN__ = d.token;
|
|
46
|
+
if (d && d.orchPort) window.__ORCH_PORT__ = d.orchPort;
|
|
47
|
+
})
|
|
35
48
|
.catch(() => {});
|
|
36
49
|
}
|
|
37
50
|
|
|
@@ -50,7 +63,7 @@ export function runSession(storyId, cmd, opts) {
|
|
|
50
63
|
body.runner = opts.runner;
|
|
51
64
|
if (opts.model) body.model = opts.model;
|
|
52
65
|
}
|
|
53
|
-
return fetch(
|
|
66
|
+
return fetch(orchHttp() + '/api/run', {
|
|
54
67
|
method: 'POST',
|
|
55
68
|
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
56
69
|
body: JSON.stringify(body),
|
|
@@ -67,7 +80,7 @@ let _runnersPromise = null;
|
|
|
67
80
|
export function fetchRunners() {
|
|
68
81
|
if (_runnersPromise) return _runnersPromise;
|
|
69
82
|
const tok = orchToken();
|
|
70
|
-
_runnersPromise = fetch(
|
|
83
|
+
_runnersPromise = fetch(orchHttp() + '/api/runners', {
|
|
71
84
|
headers: { 'Authorization': 'Bearer ' + tok },
|
|
72
85
|
})
|
|
73
86
|
.then(r => r.json())
|
|
@@ -81,7 +94,7 @@ export function fetchRunners() {
|
|
|
81
94
|
*/
|
|
82
95
|
export function stopSession(storyId) {
|
|
83
96
|
const tok = orchToken();
|
|
84
|
-
return fetch(
|
|
97
|
+
return fetch(orchHttp() + '/api/stop', {
|
|
85
98
|
method: 'POST',
|
|
86
99
|
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
87
100
|
body: JSON.stringify({ storyId }),
|
|
@@ -96,7 +109,7 @@ export function stopSession(storyId) {
|
|
|
96
109
|
function fetchSessionsWithStatus() {
|
|
97
110
|
const tok = orchToken();
|
|
98
111
|
if (!tok) return Promise.resolve({ ok: false, sessions: [] });
|
|
99
|
-
return fetch(
|
|
112
|
+
return fetch(orchHttp() + '/api/sessions', {
|
|
100
113
|
headers: { 'Authorization': 'Bearer ' + tok },
|
|
101
114
|
})
|
|
102
115
|
.then(r => {
|
|
@@ -119,7 +132,7 @@ export function fetchSessions() {
|
|
|
119
132
|
export function fetchHistory() {
|
|
120
133
|
const tok = orchToken();
|
|
121
134
|
if (!tok) return Promise.resolve([]);
|
|
122
|
-
return fetch(
|
|
135
|
+
return fetch(orchHttp() + '/api/history', { headers: { 'Authorization': 'Bearer ' + tok } })
|
|
123
136
|
.then(r => {
|
|
124
137
|
if (r.status === 401) { refreshOrchToken(); return []; }
|
|
125
138
|
return r.json().then(d => (d && d.history) || []);
|
|
@@ -160,7 +173,7 @@ export function isOrchOnline() {
|
|
|
160
173
|
*/
|
|
161
174
|
export function submitRejection(storyId, reason, phase) {
|
|
162
175
|
const tok = orchToken();
|
|
163
|
-
return fetch(
|
|
176
|
+
return fetch(orchHttp() + '/api/reject', {
|
|
164
177
|
method: 'POST',
|
|
165
178
|
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
166
179
|
body: JSON.stringify({ storyId, reason, phase: phase || null }),
|
|
@@ -173,7 +186,7 @@ export function submitRejection(storyId, reason, phase) {
|
|
|
173
186
|
export function fetchRejections() {
|
|
174
187
|
const tok = orchToken();
|
|
175
188
|
if (!tok) return Promise.resolve([]);
|
|
176
|
-
return fetch(
|
|
189
|
+
return fetch(orchHttp() + '/api/rejections', { headers: { 'Authorization': 'Bearer ' + tok } })
|
|
177
190
|
.then(r => r.ok ? r.json().then(d => (d && d.rejections) || []) : [])
|
|
178
191
|
.catch(() => []);
|
|
179
192
|
}
|
|
@@ -184,7 +197,7 @@ export function fetchRejections() {
|
|
|
184
197
|
*/
|
|
185
198
|
export function setTaskStatus(storyId, status) {
|
|
186
199
|
const tok = orchToken();
|
|
187
|
-
return fetch(
|
|
200
|
+
return fetch(orchHttp() + '/api/task-status', {
|
|
188
201
|
method: 'POST',
|
|
189
202
|
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
190
203
|
body: JSON.stringify({ storyId, status }),
|
|
@@ -197,7 +210,7 @@ export function setTaskStatus(storyId, status) {
|
|
|
197
210
|
*/
|
|
198
211
|
export function cleanSessions(olderThanDays = 0) {
|
|
199
212
|
const tok = orchToken();
|
|
200
|
-
return fetch(
|
|
213
|
+
return fetch(orchHttp() + '/api/clean-sessions', {
|
|
201
214
|
method: 'POST',
|
|
202
215
|
headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' },
|
|
203
216
|
body: JSON.stringify({ olderThanDays }),
|
|
@@ -23,6 +23,7 @@ let _state = {
|
|
|
23
23
|
timeline: _seed.timeline || null,
|
|
24
24
|
tasks: _seed.tasks || null,
|
|
25
25
|
health: _seed.health || null,
|
|
26
|
+
backlog: _seed.backlog || [],
|
|
26
27
|
// Fields injected by client.js / window.__S__
|
|
27
28
|
phases: _seed.phases || [],
|
|
28
29
|
milestone: _seed.milestone || '',
|
|
@@ -81,11 +82,57 @@ let _state = {
|
|
|
81
82
|
// renders and the spawn waits for explicit user approval.
|
|
82
83
|
// { kind: 'story'|'command', storyId?, cmd, title, opts }
|
|
83
84
|
runConfirm: null,
|
|
85
|
+
// Global file-viewer drawer state — opens FileReader over whatever view is
|
|
86
|
+
// currently active (Overview, Phases, Sprints, Tasks, Roadmap, ...) without
|
|
87
|
+
// navigating away, unlike the older requestedFile→Files-view bridge above.
|
|
88
|
+
// { path, title } | null. Driven by openFileViewer()/closeFileViewer() below.
|
|
89
|
+
fileViewer: null,
|
|
90
|
+
// Global decision-detail drawer state. Decisions have no backing file (they
|
|
91
|
+
// live as plain records in state.json), so this shows the record's own
|
|
92
|
+
// fields rather than fetching anything. The raw decision object | null.
|
|
93
|
+
decisionViewer: null,
|
|
94
|
+
// Global blocker-detail drawer state — same reasoning as decisionViewer:
|
|
95
|
+
// blockers are plain { title, desc, severity } records with no backing
|
|
96
|
+
// file. The raw blocker object | null.
|
|
97
|
+
blockerViewer: null,
|
|
84
98
|
};
|
|
85
99
|
|
|
86
100
|
/** Registered subscriber functions. */
|
|
87
101
|
const _subscribers = new Set();
|
|
88
102
|
|
|
103
|
+
/** Open the global file-viewer drawer over the current view. No-op when path is falsy. */
|
|
104
|
+
export function openFileViewer(path, title) {
|
|
105
|
+
if (!path) return;
|
|
106
|
+
setState({ fileViewer: { path, title: title || path.split('/').pop() } });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Close the global file-viewer drawer. */
|
|
110
|
+
export function closeFileViewer() {
|
|
111
|
+
setState({ fileViewer: null });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Open the global decision-detail drawer. No-op when decision is falsy. */
|
|
115
|
+
export function openDecisionViewer(decision) {
|
|
116
|
+
if (!decision) return;
|
|
117
|
+
setState({ decisionViewer: decision });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Close the global decision-detail drawer. */
|
|
121
|
+
export function closeDecisionViewer() {
|
|
122
|
+
setState({ decisionViewer: null });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Open the global blocker-detail drawer. No-op when blocker is falsy. */
|
|
126
|
+
export function openBlockerViewer(blocker) {
|
|
127
|
+
if (!blocker) return;
|
|
128
|
+
setState({ blockerViewer: blocker });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Close the global blocker-detail drawer. */
|
|
132
|
+
export function closeBlockerViewer() {
|
|
133
|
+
setState({ blockerViewer: null });
|
|
134
|
+
}
|
|
135
|
+
|
|
89
136
|
/** Return a shallow copy of the current state. */
|
|
90
137
|
export function getState() {
|
|
91
138
|
return { ..._state };
|