@hanzlaa/rcode 4.1.1 → 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/AGENTS.md +1 -1
- package/CONTRIBUTING.md +3 -0
- package/README.md +3 -0
- package/cli/agent.js +3 -1
- package/cli/index.js +29 -0
- package/cli/install.js +233 -15
- 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/cli/workflow.js +3 -1
- package/dist/rcode.js +241 -227
- package/package.json +1 -1
- package/rcode/bin/rcode-tools.cjs +15 -6
- package/rcode/commands/scaffold-project.md +2 -2
- package/rcode/skills/actions/2-plan/rcode-create-epics-and-stories/steps/step-04-final-validation.md +1 -1
- package/rcode/skills/actions/2-plan/rcode-create-milestone/steps/README.md +2 -2
- package/rcode/skills/actions/2-plan/rcode-create-milestone/steps/step-09-state-sync.md +1 -1
- package/rcode/skills/actions/4-implementation/rcode-code-review/steps/step-02-review.md +1 -1
- package/rcode/skills/actions/4-implementation/rcode-git-flow/SKILL.md +1 -1
- package/rcode/skills/actions/4-implementation/rcode-scaffold-project/SKILL.md +39 -12
- package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-01-target.md +18 -3
- package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-02-safety.md +27 -3
- package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-03-brownfield.md +57 -0
- package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-03-clone.md +4 -1
- package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-04-post-setup.md +15 -1
- package/rcode/skills/actions/4-implementation/rcode-trim/SKILL.md +1 -1
- package/rcode/workflows/audit-milestone.md +1 -1
- package/rcode/workflows/discuss-phase.md +1 -1
- package/rcode/workflows/execute-milestone.md +1 -1
- package/rcode/workflows/execute-regression-gates.md +3 -0
- package/rcode/workflows/execute-sprint.md +27 -1
- package/rcode/workflows/execute-waves.md +6 -0
- package/rcode/workflows/execute.md +13 -3
- package/rcode/workflows/new-milestone.md +2 -2
- package/rcode/workflows/new-project.md +4 -0
- package/rcode/workflows/plan-research-validation.md +1 -1
- package/rcode/workflows/plan-spawn-planner.md +2 -2
- package/rcode/workflows/plan.md +34 -15
- package/rcode/workflows/review.md +2 -0
- package/rcode/workflows/scaffold-project.md +5 -1
- package/rcode/workflows/session-report.md +1 -1
- package/rcode/workflows/ship.md +39 -0
- package/rcode/workflows/sprint-planning.md +27 -0
- package/rcode/workflows/status.md +3 -3
- 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,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotifyCenter — blocked-session notification UI.
|
|
3
|
+
*
|
|
4
|
+
* Two components, both driven by store state written by notify.js:
|
|
5
|
+
*
|
|
6
|
+
* BlockedToasts — persistent corner toasts, one per blocked-session alert
|
|
7
|
+
* (store.blockedAlerts). Clicking a toast opens that session's terminal
|
|
8
|
+
* panel; the ✕ dismisses without opening. Mounted once in App.js.
|
|
9
|
+
*
|
|
10
|
+
* BlockedBell — topbar bell with a count of currently-blocked sessions and
|
|
11
|
+
* a dropdown listing them (click an entry → open its terminal). Mounted
|
|
12
|
+
* in Topbar.js. Renders nothing special when no session is blocked.
|
|
13
|
+
*
|
|
14
|
+
* No inline style= — all styling via .nb-* classes in css.js.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { html, useState, useEffect, useRef } from '../preact.js';
|
|
18
|
+
import { useStore } from '../store.js';
|
|
19
|
+
import { openTermPanel } from '../orchestrator.js';
|
|
20
|
+
import { dismissBlockedAlert } from '../notify.js';
|
|
21
|
+
import { Icon } from '../icons-client.js';
|
|
22
|
+
|
|
23
|
+
// ── Persistent blocked toasts ─────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export function BlockedToasts() {
|
|
26
|
+
const blockedAlerts = useStore(s => s.blockedAlerts);
|
|
27
|
+
const alerts = blockedAlerts || [];
|
|
28
|
+
if (alerts.length === 0) return null;
|
|
29
|
+
|
|
30
|
+
function open(storyId) {
|
|
31
|
+
dismissBlockedAlert(storyId);
|
|
32
|
+
openTermPanel(storyId, storyId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return html`
|
|
36
|
+
<div class="nb-toasts" role="status" aria-live="polite">
|
|
37
|
+
${alerts.map(a => html`
|
|
38
|
+
<div key=${a.storyId} class="nb-toast"
|
|
39
|
+
role="button" tabindex="0"
|
|
40
|
+
title=${'Open terminal for ' + a.storyId}
|
|
41
|
+
onClick=${() => open(a.storyId)}
|
|
42
|
+
onKeyDown=${e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(a.storyId); } }}>
|
|
43
|
+
<span class="nb-toast-dot" aria-hidden="true"></span>
|
|
44
|
+
<span class="nb-toast-text">
|
|
45
|
+
Agent waiting for input — ${a.storyId}
|
|
46
|
+
${a.cmd ? html`<span class="nb-toast-cmd">${a.cmd}</span>` : null}
|
|
47
|
+
</span>
|
|
48
|
+
<button class="nb-toast-dismiss" aria-label="Dismiss"
|
|
49
|
+
onClick=${e => { e.stopPropagation(); dismissBlockedAlert(a.storyId); }}>
|
|
50
|
+
<${Icon} name="x" size=${12}/>
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
`)}
|
|
54
|
+
</div>
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Topbar bell ───────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export function BlockedBell() {
|
|
61
|
+
const activeSessions = useStore(s => s.activeSessions);
|
|
62
|
+
const [open, setOpen] = useState(false);
|
|
63
|
+
const rootRef = useRef(null);
|
|
64
|
+
const blocked = (activeSessions || []).filter(s => s.status === 'blocked');
|
|
65
|
+
|
|
66
|
+
// Close the dropdown on any outside click.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!open) return;
|
|
69
|
+
function onDocClick(e) {
|
|
70
|
+
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
|
71
|
+
}
|
|
72
|
+
document.addEventListener('mousedown', onDocClick);
|
|
73
|
+
return () => document.removeEventListener('mousedown', onDocClick);
|
|
74
|
+
}, [open]);
|
|
75
|
+
|
|
76
|
+
// Nothing blocked → drop a stale open dropdown.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (blocked.length === 0 && open) setOpen(false);
|
|
79
|
+
}, [blocked.length]);
|
|
80
|
+
|
|
81
|
+
function openSession(storyId) {
|
|
82
|
+
setOpen(false);
|
|
83
|
+
dismissBlockedAlert(storyId);
|
|
84
|
+
openTermPanel(storyId, storyId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return html`
|
|
88
|
+
<div class="nb-bell-wrap" ref=${rootRef}>
|
|
89
|
+
<button
|
|
90
|
+
class=${'tb-btn tb-btn--icon nb-bell' + (blocked.length ? ' nb-bell--alert' : '')}
|
|
91
|
+
type="button"
|
|
92
|
+
title=${blocked.length
|
|
93
|
+
? blocked.length + ' session' + (blocked.length === 1 ? '' : 's') + ' waiting for input'
|
|
94
|
+
: 'No sessions waiting for input'}
|
|
95
|
+
aria-label="Blocked session notifications"
|
|
96
|
+
aria-expanded=${open}
|
|
97
|
+
onClick=${() => { if (blocked.length) setOpen(o => !o); }}>
|
|
98
|
+
<${Icon} name="bell" size=${15}/>
|
|
99
|
+
${blocked.length ? html`<span class="nb-bell-count">${blocked.length}</span>` : null}
|
|
100
|
+
</button>
|
|
101
|
+
${open && blocked.length ? html`
|
|
102
|
+
<div class="nb-bell-dropdown" role="menu">
|
|
103
|
+
<div class="nb-bell-title">Waiting for input</div>
|
|
104
|
+
${blocked.map(s => html`
|
|
105
|
+
<button key=${s.storyId} class="nb-bell-item" role="menuitem"
|
|
106
|
+
title=${'Open terminal for ' + s.storyId}
|
|
107
|
+
onClick=${() => openSession(s.storyId)}>
|
|
108
|
+
<span class="term-status-dot blocked" aria-hidden="true"></span>
|
|
109
|
+
<span class="nb-bell-item-id">${s.storyId}</span>
|
|
110
|
+
<span class="nb-bell-item-cmd">${s.cmd || ''}</span>
|
|
111
|
+
</button>
|
|
112
|
+
`)}
|
|
113
|
+
</div>
|
|
114
|
+
` : null}
|
|
115
|
+
</div>
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
|
|
17
17
|
import { useStore, setState } from '../store.js';
|
|
18
|
-
import { orchToken, stopSession, cleanSessions,
|
|
18
|
+
import { orchToken, stopSession, cleanSessions, ORCH_WS } from '../orchestrator.js';
|
|
19
19
|
import { showToast } from './shared.js';
|
|
20
20
|
import { Icon } from '../icons-client.js';
|
|
21
21
|
|
|
@@ -25,7 +25,23 @@ function mkSession(title) {
|
|
|
25
25
|
return { title: title || 'Session', lines: [], fileOps: [], status: 'starting' };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Strip ANSI escape sequences (OSC, CSI, other ESC) and carriage returns so
|
|
30
|
+
* raw PTY output renders as readable plain-text log lines. The full-fidelity
|
|
31
|
+
* terminal lives in XtermPanel; this panel is a lightweight log view.
|
|
32
|
+
*/
|
|
33
|
+
function stripAnsi(s) {
|
|
34
|
+
return String(s)
|
|
35
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
36
|
+
.replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, '')
|
|
37
|
+
.replace(/\x1b[@-_]/g, '')
|
|
38
|
+
.replace(/\r/g, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Live streams (module-scoped — one WebSocket per storyId) ─────────────────
|
|
42
|
+
// The orchestrator's data plane is the PTY WebSocket at /ws/<storyId>
|
|
43
|
+
// (wire frames: {t:'o',d} output, {t:'hist',d} scrollback, {t:'s',s} status).
|
|
44
|
+
// The previous SSE endpoint (/api/stream/<id>) no longer exists on the server.
|
|
29
45
|
const _streams = {};
|
|
30
46
|
|
|
31
47
|
function closeStream(storyId) {
|
|
@@ -35,9 +51,11 @@ function closeStream(storyId) {
|
|
|
35
51
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
36
52
|
|
|
37
53
|
export function OrchPanel() {
|
|
38
|
-
const { orchPanel } = useStore();
|
|
54
|
+
const { orchPanel, activeSessions } = useStore();
|
|
39
55
|
const open = !!(orchPanel && orchPanel.open);
|
|
40
56
|
const reqStory = orchPanel && orchPanel.storyId;
|
|
57
|
+
// Sessions reported by the orchestrator API (4s poll → store.activeSessions).
|
|
58
|
+
const apiSessions = activeSessions || [];
|
|
41
59
|
|
|
42
60
|
// sessionsMap: { [storyId]: { title, lines, fileOps, status } }
|
|
43
61
|
const [sessionsMap, setSessionsMap] = useState({});
|
|
@@ -72,11 +90,15 @@ export function OrchPanel() {
|
|
|
72
90
|
|
|
73
91
|
function connectStream(storyId) {
|
|
74
92
|
const tok = orchToken();
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
if (!tok) {
|
|
94
|
+
showToast('No orchestrator token — restart the dashboard');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const ws = new WebSocket(
|
|
98
|
+
ORCH_WS + '/ws/' + encodeURIComponent(storyId) +
|
|
99
|
+
'?token=' + encodeURIComponent(tok)
|
|
78
100
|
);
|
|
79
|
-
_streams[storyId] =
|
|
101
|
+
_streams[storyId] = ws;
|
|
80
102
|
|
|
81
103
|
function appendLine(storyId, line, cls) {
|
|
82
104
|
setSessionsMap(prev => {
|
|
@@ -89,32 +111,24 @@ export function OrchPanel() {
|
|
|
89
111
|
});
|
|
90
112
|
}
|
|
91
113
|
|
|
114
|
+
// Append a multi-line output chunk: the first segment continues the last
|
|
115
|
+
// streamed line, each newline starts a fresh line element.
|
|
92
116
|
function appendChunk(storyId, chunk) {
|
|
93
117
|
setSessionsMap(prev => {
|
|
94
118
|
const sess = prev[storyId];
|
|
95
119
|
if (!sess) return prev;
|
|
96
|
-
const
|
|
120
|
+
const parts = chunk.split('\n');
|
|
121
|
+
const lines = [...sess.lines];
|
|
97
122
|
const last = lines[lines.length - 1];
|
|
98
123
|
if (last && last.cls === 'kt-stream') {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
lines[lines.length - 1] = { ...last, text: last.text + parts[0] };
|
|
125
|
+
} else if (parts[0]) {
|
|
126
|
+
lines.push({ text: parts[0], cls: 'kt-stream' });
|
|
102
127
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
};
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function appendFileOp(storyId, fileOp) {
|
|
111
|
-
setSessionsMap(prev => {
|
|
112
|
-
const sess = prev[storyId];
|
|
113
|
-
if (!sess) return prev;
|
|
114
|
-
return {
|
|
115
|
-
...prev,
|
|
116
|
-
[storyId]: { ...sess, fileOps: [...sess.fileOps, fileOp] },
|
|
117
|
-
};
|
|
128
|
+
for (let i = 1; i < parts.length; i++) {
|
|
129
|
+
lines.push({ text: parts[i], cls: 'kt-stream' });
|
|
130
|
+
}
|
|
131
|
+
return { ...prev, [storyId]: { ...sess, lines } };
|
|
118
132
|
});
|
|
119
133
|
}
|
|
120
134
|
|
|
@@ -126,41 +140,39 @@ export function OrchPanel() {
|
|
|
126
140
|
});
|
|
127
141
|
}
|
|
128
142
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
if (d.fileOp) appendFileOp(storyId, d.fileOp);
|
|
144
|
-
if (d.status) {
|
|
145
|
-
setTabStatus(storyId, d.status);
|
|
146
|
-
if (d.status === 'done') appendLine(storyId, '✅ Done', 'kt-line done-line');
|
|
147
|
-
if (d.status === 'stopped') appendLine(storyId, '■ Stopped', 'kt-line meta');
|
|
148
|
-
if (d.status !== 'running') { closeStream(storyId); }
|
|
149
|
-
}
|
|
150
|
-
} catch { /* ignore parse errors */ }
|
|
143
|
+
ws.onmessage = e => {
|
|
144
|
+
let d;
|
|
145
|
+
try { d = JSON.parse(e.data); } catch { return; }
|
|
146
|
+
if (!d) return;
|
|
147
|
+
if (d.t === 'o' || d.t === 'hist') {
|
|
148
|
+
const text = stripAnsi(d.d);
|
|
149
|
+
if (text) appendChunk(storyId, text);
|
|
150
|
+
} else if (d.t === 's') {
|
|
151
|
+
setTabStatus(storyId, d.s);
|
|
152
|
+
if (d.s === 'done') appendLine(storyId, '✅ Done', 'kt-line done-line');
|
|
153
|
+
if (d.s === 'stopped') appendLine(storyId, '■ Stopped', 'kt-line meta');
|
|
154
|
+
if (d.s !== 'running' && d.s !== 'starting') closeStream(storyId);
|
|
155
|
+
}
|
|
151
156
|
};
|
|
152
|
-
|
|
157
|
+
ws.onerror = () => {
|
|
153
158
|
setTabStatus(storyId, 'error');
|
|
154
159
|
closeStream(storyId);
|
|
155
160
|
};
|
|
161
|
+
ws.onclose = () => {
|
|
162
|
+
if (_streams[storyId] === ws) delete _streams[storyId];
|
|
163
|
+
};
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
const handleClose = useCallback(() => {
|
|
159
167
|
setState({ orchPanel: null });
|
|
160
168
|
}, []);
|
|
161
169
|
|
|
170
|
+
// Open (or focus) a session tab and attach its live stream. Used both for
|
|
171
|
+
// locally-opened tabs and for sessions discovered via the orchestrator API.
|
|
162
172
|
function handleTabClick(storyId) {
|
|
173
|
+
setSessionsMap(prev => prev[storyId] ? prev : { ...prev, [storyId]: mkSession(storyId) });
|
|
163
174
|
setActiveTab(storyId);
|
|
175
|
+
if (!_streams[storyId]) connectStream(storyId);
|
|
164
176
|
}
|
|
165
177
|
|
|
166
178
|
function handleTabClose(e, storyId) {
|
|
@@ -197,9 +209,14 @@ export function OrchPanel() {
|
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
const tabs = Object.keys(sessionsMap);
|
|
212
|
+
// Sessions the orchestrator knows about that aren't open as tabs yet —
|
|
213
|
+
// rendered as clickable entries so the panel reflects the API, not just
|
|
214
|
+
// locally-opened tabs.
|
|
215
|
+
const apiOnly = apiSessions.filter(s => s.storyId && !sessionsMap[s.storyId]);
|
|
200
216
|
const activeSess = activeTab ? sessionsMap[activeTab] : null;
|
|
201
217
|
const hasStream = activeTab && !!_streams[activeTab];
|
|
202
|
-
|
|
218
|
+
// 'blocked' = live PTY waiting for input — still a live session.
|
|
219
|
+
const runningCount = apiSessions.filter(s => s.status === 'running' || s.status === 'blocked').length;
|
|
203
220
|
|
|
204
221
|
const panelCls = 'orch-panel' + (open ? ' open' : '');
|
|
205
222
|
|
|
@@ -213,9 +230,9 @@ export function OrchPanel() {
|
|
|
213
230
|
<button class="orch-panel-close" onClick=${handleClose} title="Close" aria-label="Close panel"><${Icon} name="x" size=${14}/></button>
|
|
214
231
|
</div>
|
|
215
232
|
|
|
216
|
-
<!-- Tab strip -->
|
|
233
|
+
<!-- Tab strip — open tabs first, then API-known sessions not yet opened -->
|
|
217
234
|
<div class="orch-tabs">
|
|
218
|
-
${tabs.length === 0 ? html`
|
|
235
|
+
${tabs.length === 0 && apiOnly.length === 0 ? html`
|
|
219
236
|
<div class="orch-term-empty orch-empty-tab">
|
|
220
237
|
No active sessions
|
|
221
238
|
</div>
|
|
@@ -239,6 +256,17 @@ export function OrchPanel() {
|
|
|
239
256
|
</button>
|
|
240
257
|
`;
|
|
241
258
|
})}
|
|
259
|
+
${apiOnly.map(s => html`
|
|
260
|
+
<button
|
|
261
|
+
key=${s.storyId}
|
|
262
|
+
class="orch-tab"
|
|
263
|
+
onClick=${() => handleTabClick(s.storyId)}
|
|
264
|
+
title=${'Attach to ' + s.storyId}
|
|
265
|
+
>
|
|
266
|
+
<span class=${'tab-status-dot ' + (s.status || 'starting')}></span>
|
|
267
|
+
<span>${s.storyId.slice(0, 20)}</span>
|
|
268
|
+
</button>
|
|
269
|
+
`)}
|
|
242
270
|
</div>
|
|
243
271
|
|
|
244
272
|
<!-- Terminal body -->
|
|
@@ -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
|
+
}
|