@hanzlaa/rcode 4.1.2 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/install.js +176 -13
- package/cli/lib/config.cjs +4 -2
- package/cli/lib/fsutil.cjs +13 -2
- package/cli/lib/homedir.cjs +21 -0
- package/cli/lib/schemas.cjs +6 -1
- package/cli/nuke.js +13 -8
- package/cli/postinstall.js +14 -4
- package/cli/rcode-slash-router.cjs +118 -0
- package/cli/uninstall.js +59 -1
- package/cli/update.js +10 -5
- package/dist/rcode.js +234 -230
- package/package.json +1 -1
- package/server/dashboard.js +26 -7
- package/server/lib/api.js +62 -4
- package/server/lib/html/client/agents-data.js +22 -18
- package/server/lib/html/client/app.js +3 -0
- package/server/lib/html/client/components/AgentCard.js +127 -0
- package/server/lib/html/client/components/App.js +104 -39
- package/server/lib/html/client/components/CommandPalette.js +133 -0
- package/server/lib/html/client/components/FileReader.js +116 -0
- package/server/lib/html/client/components/FilterChips.js +94 -0
- package/server/lib/html/client/components/NotifyCenter.js +117 -0
- package/server/lib/html/client/components/OrchPanel.js +80 -52
- package/server/lib/html/client/components/PhaseGraph.js +300 -0
- package/server/lib/html/client/components/RejectDialog.js +78 -0
- package/server/lib/html/client/components/RunnerPicker.js +190 -0
- package/server/lib/html/client/components/Sidebar.js +106 -61
- package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
- package/server/lib/html/client/components/TaskPipeline.js +83 -0
- package/server/lib/html/client/components/Topbar.js +86 -39
- package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
- package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
- package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
- package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
- package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
- package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
- package/server/lib/html/client/components/shared.js +47 -11
- package/server/lib/html/client/filter-state.js +72 -0
- package/server/lib/html/client/icons-client.js +7 -0
- package/server/lib/html/client/notify.js +75 -0
- package/server/lib/html/client/orchestrator.js +168 -41
- package/server/lib/html/client/preact.js +13 -8
- package/server/lib/html/client/store.js +70 -6
- package/server/lib/html/client/util.js +78 -0
- package/server/lib/html/client/vendor/htm.js +1 -0
- package/server/lib/html/client/vendor/preact-hooks.js +2 -0
- package/server/lib/html/client/vendor/preact.js +2 -0
- package/server/lib/html/client/views/AgentsView.js +144 -51
- package/server/lib/html/client/views/FilesView.js +20 -103
- package/server/lib/html/client/views/KanbanView.js +40 -21
- package/server/lib/html/client/views/MemoryView.js +26 -9
- package/server/lib/html/client/views/MilestonesView.js +4 -4
- package/server/lib/html/client/views/OrchestrationView.js +154 -19
- package/server/lib/html/client/views/OverviewView.js +47 -239
- package/server/lib/html/client/views/PhasesView.js +50 -6
- package/server/lib/html/client/views/RoadmapView.js +6 -3
- package/server/lib/html/client/views/SprintsView.js +50 -6
- package/server/lib/html/client/views/TasksView.js +4 -3
- package/server/lib/html/client.js +21 -4
- package/server/lib/html/css.js +2761 -8
- package/server/lib/html/icons.js +7 -0
- package/server/lib/html/shell.js +10 -3
- package/server/lib/scanner.js +376 -39
- package/server/orchestrator.js +329 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzlaa/rcode",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "rcode — the AI team that never forgets. Persistent memory, specialist agents, and slash commands for AI IDEs. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
|
|
5
5
|
"main": "cli/index.js",
|
|
6
6
|
"bin": {
|
package/server/dashboard.js
CHANGED
|
@@ -31,7 +31,7 @@ const { spawn } = require('child_process');
|
|
|
31
31
|
const CLIENT_DIR = path.join(__dirname, 'lib', 'html', 'client');
|
|
32
32
|
|
|
33
33
|
const { scanState } = require('./lib/scanner');
|
|
34
|
-
const { handleApiState, handleApiFiles, handleApiFile, handleApiHierarchy, handleApiMemory } = require('./lib/api');
|
|
34
|
+
const { handleApiState, handleApiFiles, handleApiFile, handleApiHierarchy, handleApiMemory, handleApiAgents } = require('./lib/api');
|
|
35
35
|
const { renderHtml } = require('./lib/html/shell');
|
|
36
36
|
|
|
37
37
|
// ---------- Configuration ----------
|
|
@@ -60,7 +60,20 @@ function loadOrchToken() {
|
|
|
60
60
|
const ORCH_TOKEN = loadOrchToken();
|
|
61
61
|
|
|
62
62
|
// ---------- HTTP Server ----------
|
|
63
|
+
// Every request runs through a try/catch so an unanticipated throw inside a
|
|
64
|
+
// handler (e.g. a pathological .planning tree in the scanner) returns a 500
|
|
65
|
+
// instead of crashing the whole server process.
|
|
63
66
|
const server = http.createServer((req, res) => {
|
|
67
|
+
try {
|
|
68
|
+
handleRequest(req, res);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error('[dashboard] request handler failed:', err && err.stack || err);
|
|
71
|
+
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
72
|
+
res.end('Internal server error');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function handleRequest(req, res) {
|
|
64
77
|
const url = req.url || '/';
|
|
65
78
|
|
|
66
79
|
if (url === '/health') {
|
|
@@ -79,6 +92,11 @@ const server = http.createServer((req, res) => {
|
|
|
79
92
|
return;
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
if (url === '/api/agents') {
|
|
96
|
+
handleApiAgents(req, res, PROJECT_ROOT);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
82
100
|
if (url.startsWith('/api/file')) {
|
|
83
101
|
handleApiFile(req, res, PROJECT_ROOT);
|
|
84
102
|
return;
|
|
@@ -104,10 +122,12 @@ const server = http.createServer((req, res) => {
|
|
|
104
122
|
|
|
105
123
|
if (url.startsWith('/js/')) {
|
|
106
124
|
const name = url.slice(4).split('?')[0];
|
|
107
|
-
// Allow
|
|
108
|
-
// while still rejecting traversal
|
|
109
|
-
//
|
|
110
|
-
|
|
125
|
+
// Allow nested subdirectories (e.g. components/App.js, views/Foo.js,
|
|
126
|
+
// components/dashboard/ProgressDonut.js) while still rejecting traversal.
|
|
127
|
+
// The regex limits each segment to word chars, dots, and hyphens; the
|
|
128
|
+
// resolved-path check below is the real traversal guard (a `..` segment
|
|
129
|
+
// would pass this pattern but fail the CLIENT_DIR containment check).
|
|
130
|
+
if (!/^(?:[\w.-]+\/)*[\w.-]+\.js$/.test(name)) { res.writeHead(404); res.end('Not found'); return; }
|
|
111
131
|
// Defense-in-depth: resolved path must stay inside CLIENT_DIR even after
|
|
112
132
|
// any OS-level resolution (handles encoded traversal the regex might miss).
|
|
113
133
|
const resolved = path.resolve(CLIENT_DIR, name);
|
|
@@ -135,7 +155,7 @@ const server = http.createServer((req, res) => {
|
|
|
135
155
|
|
|
136
156
|
res.writeHead(404);
|
|
137
157
|
res.end('Not found');
|
|
138
|
-
}
|
|
158
|
+
}
|
|
139
159
|
|
|
140
160
|
server.listen(PORT, '127.0.0.1', () => {
|
|
141
161
|
console.log(`\n🕌 Majlis (مجلس) — rcode Dashboard`);
|
|
@@ -144,7 +164,6 @@ server.listen(PORT, '127.0.0.1', () => {
|
|
|
144
164
|
console.log(` URL: http://localhost:${PORT}`);
|
|
145
165
|
console.log(` Scanning: ${RCODE_DIR}`);
|
|
146
166
|
console.log(` Refresh: 30s soft poll`);
|
|
147
|
-
console.log(` Keys: R=refresh 1-9=views F=filter`);
|
|
148
167
|
console.log(` Stop: kill $(ss -ltnp 'sport = :${PORT}' | awk 'NR>1{match($6,/pid=([0-9]+)/,m); print m[1]}')`);
|
|
149
168
|
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
|
150
169
|
});
|
package/server/lib/api.js
CHANGED
|
@@ -8,7 +8,8 @@ const { scanState, scanMemoryBank } = require('./scanner');
|
|
|
8
8
|
function handleApiState(req, res, rcodeDir) {
|
|
9
9
|
const state = scanState(rcodeDir);
|
|
10
10
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
11
|
-
|
|
11
|
+
// Compact JSON — pretty-printing roughly doubled the polled payload.
|
|
12
|
+
res.end(JSON.stringify(state));
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
function handleApiFiles(req, res, projectRoot) {
|
|
@@ -192,13 +193,70 @@ function handleApiHierarchy(req, res, rcodeDir) {
|
|
|
192
193
|
})),
|
|
193
194
|
};
|
|
194
195
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
195
|
-
res.end(JSON.stringify(hierarchy
|
|
196
|
+
res.end(JSON.stringify(hierarchy));
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
function handleApiMemory(req, res, rcodeDir) {
|
|
199
200
|
const memory = scanMemoryBank(rcodeDir);
|
|
200
201
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
201
|
-
res.end(JSON.stringify(memory
|
|
202
|
+
res.end(JSON.stringify(memory));
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
// Parse the keys we surface on agent cards out of an agent definition's
|
|
206
|
+
// YAML frontmatter. Deliberately not a YAML parser: top-level `key: value`
|
|
207
|
+
// scalar lines are read directly; for `description` (usually a `|` block
|
|
208
|
+
// scalar) the first two indented lines are captured as a card-sized summary.
|
|
209
|
+
function parseAgentFrontmatter(raw) {
|
|
210
|
+
const meta = { name: null, model: null, tools: [], color: null, description: null };
|
|
211
|
+
if (!raw.startsWith('---')) return meta;
|
|
212
|
+
const end = raw.indexOf('\n---', 3);
|
|
213
|
+
if (end === -1) return meta;
|
|
214
|
+
let inDescription = false;
|
|
215
|
+
const descLines = [];
|
|
216
|
+
for (const line of raw.slice(3, end).split('\n')) {
|
|
217
|
+
const m = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/);
|
|
218
|
+
if (m) {
|
|
219
|
+
inDescription = false;
|
|
220
|
+
const key = m[1].toLowerCase();
|
|
221
|
+
const value = m[2].trim();
|
|
222
|
+
if (key === 'description') {
|
|
223
|
+
if (value && value !== '|' && value !== '>') descLines.push(value);
|
|
224
|
+
else inDescription = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!value || value === '|' || value === '>') continue;
|
|
228
|
+
if (key === 'name') meta.name = value;
|
|
229
|
+
if (key === 'model') meta.model = value;
|
|
230
|
+
if (key === 'color') meta.color = value;
|
|
231
|
+
if (key === 'tools') meta.tools = value.split(',').map(t => t.trim()).filter(Boolean);
|
|
232
|
+
} else if (inDescription && descLines.length < 2 && /^\s+\S/.test(line)) {
|
|
233
|
+
descLines.push(line.trim());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
meta.description = descLines.join(' ') || null;
|
|
237
|
+
return meta;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Read-only roster metadata for the Agents view. Scans the fixed
|
|
241
|
+
// rcode/agents/ directory (no user-supplied paths — nothing to contain) and
|
|
242
|
+
// returns one small frontmatter summary per agent .md file. Full prompt
|
|
243
|
+
// bodies are NOT included; the client fetches those lazily per agent via the
|
|
244
|
+
// existing /api/file handler when a card is opened.
|
|
245
|
+
function handleApiAgents(req, res, projectRoot) {
|
|
246
|
+
const agentsDir = path.join(projectRoot, 'rcode', 'agents');
|
|
247
|
+
let entries = [];
|
|
248
|
+
try { entries = fs.readdirSync(agentsDir, { withFileTypes: true }); }
|
|
249
|
+
catch { /* no agents dir — return an empty roster */ }
|
|
250
|
+
const agents = [];
|
|
251
|
+
for (const e of entries) {
|
|
252
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue;
|
|
253
|
+
let raw;
|
|
254
|
+
try { raw = fs.readFileSync(path.join(agentsDir, e.name), 'utf8'); }
|
|
255
|
+
catch { continue; }
|
|
256
|
+
agents.push({ file: e.name, ...parseAgentFrontmatter(raw) });
|
|
257
|
+
}
|
|
258
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
259
|
+
res.end(JSON.stringify(agents));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { handleApiState, handleApiFiles, handleApiFile, handleApiHierarchy, handleApiMemory, handleApiAgents };
|
|
@@ -3,25 +3,29 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Previously lived in shell.js:17-36 as a server-rendered array.
|
|
5
5
|
* Now exported as a pure ESM constant so AgentsView can render it.
|
|
6
|
+
*
|
|
7
|
+
* `file` is the agent's definition under rcode/agents/ — fetched lazily by
|
|
8
|
+
* AgentsView when a card is opened. null = no prompt file on disk (system
|
|
9
|
+
* entries like Raees/Majlis/Diwan are skills, not agent definitions).
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
export const AGENTS = [
|
|
9
|
-
{ name: 'Sadiq Damani', arabic: 'صادق', role: 'Director of Strategy', real: true, type: 'leadership' },
|
|
10
|
-
{ name: 'Waleed Al Harthi', arabic: 'وليد', role: 'CTO', real: true, type: 'leadership' },
|
|
11
|
-
{ name: 'Ahmed Al Hassani', arabic: 'أحمد الحسني', role: 'Technology & Development Director', real: true, type: 'leadership' },
|
|
12
|
-
{ name: 'Nasser', arabic: 'ناصر', role: 'Engineering Manager', real: true, type: 'leadership' },
|
|
13
|
-
{ name: 'Hussain', arabic: 'حسين', role: 'PM + Scrum Master', type: 'product' },
|
|
14
|
-
{ name: 'Layla', arabic: 'ليلى', role: 'Lead UX Designer', type: 'design' },
|
|
15
|
-
{ name: 'Zahra', arabic: 'زهرة', role: 'Branding & Creative Director', type: 'design' },
|
|
16
|
-
{ name: 'Omar', arabic: 'عمر', role: 'Full-Stack Engineer', type: 'engineering' },
|
|
17
|
-
{ name: 'Haitham Al Khamiyasi', arabic: 'هيثم', role: 'Senior Frontend', real: true, type: 'engineering' },
|
|
18
|
-
{ name: 'Yousef', arabic: 'يوسف', role: 'Senior Backend', type: 'engineering' },
|
|
19
|
-
{ name: 'Zayd', arabic: 'زيد', role: 'ML Engineer', type: 'engineering' },
|
|
20
|
-
{ name: 'Fatima', arabic: 'فاطمة', role: 'QA Lead', type: 'quality' },
|
|
21
|
-
{ name: 'Khalid', arabic: 'خالد', role: 'DevOps', type: 'engineering' },
|
|
22
|
-
{ name: 'Noor', arabic: 'نور', role: 'Scribe', type: 'support' },
|
|
23
|
-
{ name: 'Mariam', arabic: 'مريم', role: 'Marketing Lead', type: 'product' },
|
|
24
|
-
{ name: 'Raees', arabic: 'رئيس', role: 'Orchestration Director', type: 'system' },
|
|
25
|
-
{ name: 'Majlis', arabic: 'مجلس', role: 'Consulting Council', type: 'system' },
|
|
26
|
-
{ name: 'Diwan', arabic: 'ديوان', role: 'Dashboard Registry', type: 'system' },
|
|
13
|
+
{ name: 'Sadiq Damani', arabic: 'صادق', role: 'Director of Strategy', real: true, type: 'leadership', file: 'rcode-sadiq.md' },
|
|
14
|
+
{ name: 'Waleed Al Harthi', arabic: 'وليد', role: 'CTO', real: true, type: 'leadership', file: 'rcode-waleed.md' },
|
|
15
|
+
{ name: 'Ahmed Al Hassani', arabic: 'أحمد الحسني', role: 'Technology & Development Director', real: true, type: 'leadership', file: 'rcode-ahmed.md' },
|
|
16
|
+
{ name: 'Nasser', arabic: 'ناصر', role: 'Engineering Manager', real: true, type: 'leadership', file: 'rcode-nasser.md' },
|
|
17
|
+
{ name: 'Hussain', arabic: 'حسين', role: 'PM + Scrum Master', type: 'product', file: 'rcode-hussain-pm.md' },
|
|
18
|
+
{ name: 'Layla', arabic: 'ليلى', role: 'Lead UX Designer', type: 'design', file: 'rcode-layla.md' },
|
|
19
|
+
{ name: 'Zahra', arabic: 'زهرة', role: 'Branding & Creative Director', type: 'design', file: 'rcode-zahra.md' },
|
|
20
|
+
{ name: 'Omar', arabic: 'عمر', role: 'Full-Stack Engineer', type: 'engineering', file: 'rcode-omar.md' },
|
|
21
|
+
{ name: 'Haitham Al Khamiyasi', arabic: 'هيثم', role: 'Senior Frontend', real: true, type: 'engineering', file: 'rcode-haitham.md' },
|
|
22
|
+
{ name: 'Yousef', arabic: 'يوسف', role: 'Senior Backend', type: 'engineering', file: 'rcode-yousef.md' },
|
|
23
|
+
{ name: 'Zayd', arabic: 'زيد', role: 'ML Engineer', type: 'engineering', file: 'rcode-zayd.md' },
|
|
24
|
+
{ name: 'Fatima', arabic: 'فاطمة', role: 'QA Lead', type: 'quality', file: 'rcode-fatima.md' },
|
|
25
|
+
{ name: 'Khalid', arabic: 'خالد', role: 'DevOps', type: 'engineering', file: 'rcode-khalid.md' },
|
|
26
|
+
{ name: 'Noor', arabic: 'نور', role: 'Scribe', type: 'support', file: 'rcode-noor.md' },
|
|
27
|
+
{ name: 'Mariam', arabic: 'مريم', role: 'Marketing Lead', type: 'product', file: 'rcode-mariam.md' },
|
|
28
|
+
{ name: 'Raees', arabic: 'رئيس', role: 'Orchestration Director', type: 'system', file: null },
|
|
29
|
+
{ name: 'Majlis', arabic: 'مجلس', role: 'Consulting Council', type: 'system', file: null },
|
|
30
|
+
{ name: 'Diwan', arabic: 'ديوان', role: 'Dashboard Registry', type: 'system', file: null },
|
|
27
31
|
];
|
|
@@ -11,5 +11,8 @@ import { App } from './components/App.js';
|
|
|
11
11
|
|
|
12
12
|
const root = document.getElementById('app-root');
|
|
13
13
|
if (root) {
|
|
14
|
+
// Drop the SSR loading shell — Preact diffs against existing children,
|
|
15
|
+
// so the spinner must be gone before the first render.
|
|
16
|
+
root.textContent = '';
|
|
14
17
|
render(html`<${App}/>`, root);
|
|
15
18
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentCard — card, avatar, chips, and detail drawer for the Agents view.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from AgentsView so the view module stays focused on grouping,
|
|
5
|
+
* search, and fetch state. Per-role accent colors are driven by a single
|
|
6
|
+
* `agent-accent--<type>` class on the card/drawer root: it sets the
|
|
7
|
+
* --agent-accent custom property that the avatar, role badge, and hover
|
|
8
|
+
* border all read (see the AGENTS VIEW block at the end of css.js).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { html } from '../preact.js';
|
|
12
|
+
import { setState } from '../store.js';
|
|
13
|
+
import { pressable, showToast } from './shared.js';
|
|
14
|
+
import { renderMd } from '../util.js';
|
|
15
|
+
|
|
16
|
+
const MAX_CARD_TOOL_CHIPS = 4;
|
|
17
|
+
|
|
18
|
+
/** "Sadiq Damani" -> "SD", "Hussain" -> "H". */
|
|
19
|
+
function initialsOf(name) {
|
|
20
|
+
return name.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Per-role accent class — types map 1:1 to the CSS accent variants. */
|
|
24
|
+
export function accentClass(agent) {
|
|
25
|
+
return 'agent-accent--' + (agent.type || 'system');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---- Avatar circle with initials ----
|
|
29
|
+
function Avatar({ agent, large }) {
|
|
30
|
+
return html`<span class=${'agent-avatar' + (large ? ' agent-avatar--lg' : '')} aria-hidden="true">${initialsOf(agent.name)}</span>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---- Metadata chips (model + tools), shared by card and drawer ----
|
|
34
|
+
export function MetaChips({ meta, maxTools }) {
|
|
35
|
+
if (!meta) return null;
|
|
36
|
+
const tools = meta.tools || [];
|
|
37
|
+
const shown = maxTools ? tools.slice(0, maxTools) : tools;
|
|
38
|
+
const extra = tools.length - shown.length;
|
|
39
|
+
if (!meta.model && !shown.length) return null;
|
|
40
|
+
return html`
|
|
41
|
+
<div class="agent-chips">
|
|
42
|
+
${meta.model ? html`<span class="agent-chip agent-chip--model">${meta.model}</span>` : null}
|
|
43
|
+
${shown.map(t => html`<span class="agent-chip" key=${t}>${t}</span>`)}
|
|
44
|
+
${extra > 0 ? html`<span class="agent-chip agent-chip--more">+${extra}</span>` : null}
|
|
45
|
+
</div>
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- Single agent card ----
|
|
50
|
+
export function AgentCard({ agent, meta, onOpen }) {
|
|
51
|
+
return html`
|
|
52
|
+
<div class=${'agent-card ' + accentClass(agent)} ...${pressable(() => onOpen(agent))}>
|
|
53
|
+
<div class="agent-card-top">
|
|
54
|
+
<${Avatar} agent=${agent} />
|
|
55
|
+
<div class="agent-card-id">
|
|
56
|
+
<div class="agent-card-name">
|
|
57
|
+
${agent.name}
|
|
58
|
+
${agent.real ? html`<span class="real-badge">real</span>` : null}
|
|
59
|
+
</div>
|
|
60
|
+
<span class="role-badge">${agent.role}</span>
|
|
61
|
+
</div>
|
|
62
|
+
<span class="agent-card-arabic">${agent.arabic}</span>
|
|
63
|
+
</div>
|
|
64
|
+
${meta && meta.description ? html`<p class="agent-card-desc">${meta.description}</p>` : null}
|
|
65
|
+
<${MetaChips} meta=${meta} maxTools=${MAX_CARD_TOOL_CHIPS} />
|
|
66
|
+
</div>
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- Detail drawer ----
|
|
71
|
+
export function AgentDrawer({ agent, meta, prompt, onClose }) {
|
|
72
|
+
const filePath = agent.file ? 'rcode/agents/' + agent.file : null;
|
|
73
|
+
|
|
74
|
+
function copyPath() {
|
|
75
|
+
navigator.clipboard.writeText(filePath).then(() => {
|
|
76
|
+
showToast('Path copied!');
|
|
77
|
+
}).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function openInFiles() {
|
|
81
|
+
setState({ requestedFile: filePath });
|
|
82
|
+
window.location.hash = 'files';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let body;
|
|
86
|
+
if (!agent.file) {
|
|
87
|
+
body = html`<div class="agent-drawer-empty">No prompt file on disk — this is a system entry without an agent definition.</div>`;
|
|
88
|
+
} else if (prompt.loading) {
|
|
89
|
+
body = html`
|
|
90
|
+
<div class="skeleton"></div>
|
|
91
|
+
<div class="agent-drawer-skeleton skeleton"></div>
|
|
92
|
+
`;
|
|
93
|
+
} else if (prompt.error) {
|
|
94
|
+
body = html`<div class="agent-drawer-error">${prompt.error}</div>`;
|
|
95
|
+
} else if (prompt.text) {
|
|
96
|
+
body = html`<div class="md-render" dangerouslySetInnerHTML=${{ __html: renderMd(prompt.text) }} />`;
|
|
97
|
+
} else {
|
|
98
|
+
body = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return html`
|
|
102
|
+
<div class="agent-drawer-backdrop" onClick=${onClose}></div>
|
|
103
|
+
<aside class=${'agent-drawer ' + accentClass(agent)} role="dialog" aria-modal="true" aria-label="${agent.name} — full prompt">
|
|
104
|
+
<div class="agent-drawer-head">
|
|
105
|
+
<${Avatar} agent=${agent} large />
|
|
106
|
+
<div class="agent-drawer-titles">
|
|
107
|
+
<div class="agent-drawer-name">
|
|
108
|
+
${agent.name}
|
|
109
|
+
<span class="agent-drawer-arabic">${agent.arabic}</span>
|
|
110
|
+
${agent.real ? html`<span class="real-badge">real</span>` : null}
|
|
111
|
+
</div>
|
|
112
|
+
<span class="role-badge">${agent.role}</span>
|
|
113
|
+
<${MetaChips} meta=${meta} />
|
|
114
|
+
</div>
|
|
115
|
+
<button class="agent-drawer-close" onClick=${onClose} aria-label="Close">×</button>
|
|
116
|
+
</div>
|
|
117
|
+
${filePath ? html`
|
|
118
|
+
<div class="agent-drawer-meta">
|
|
119
|
+
<span class="agent-drawer-meta-path">${filePath}</span>
|
|
120
|
+
<button class="agent-drawer-btn" onClick=${copyPath}>Copy path</button>
|
|
121
|
+
<button class="agent-drawer-btn agent-drawer-btn--link" onClick=${openInFiles}>View in Files →</button>
|
|
122
|
+
</div>
|
|
123
|
+
` : null}
|
|
124
|
+
<div class="agent-drawer-body">${body}</div>
|
|
125
|
+
</aside>
|
|
126
|
+
`;
|
|
127
|
+
}
|
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
|
|
16
|
+
import { parseFilters } from '../filter-state.js';
|
|
16
17
|
import { getState, setState, subscribe, registerRefresh } from '../store.js';
|
|
17
18
|
import { startSessionsPoll, refreshOrchToken } from '../orchestrator.js';
|
|
18
19
|
import { Sidebar } from './Sidebar.js';
|
|
19
20
|
import { Topbar } from './Topbar.js';
|
|
20
21
|
import { XtermPanel } from './XtermPanel.js';
|
|
21
22
|
import { OrchPanel } from './OrchPanel.js';
|
|
23
|
+
import { RunnerPicker } from './RunnerPicker.js';
|
|
24
|
+
import { CommandPalette } from './CommandPalette.js';
|
|
25
|
+
import { BlockedToasts } from './NotifyCenter.js';
|
|
22
26
|
import { OverviewView } from '../views/OverviewView.js';
|
|
23
27
|
import { DecisionsView } from '../views/DecisionsView.js';
|
|
24
28
|
import { RoadmapView } from '../views/RoadmapView.js';
|
|
@@ -54,41 +58,59 @@ const LEGACY_VIEWS = [];
|
|
|
54
58
|
|
|
55
59
|
const ALL_VIEWS = Object.keys(PREACT_VIEWS).concat(LEGACY_VIEWS);
|
|
56
60
|
|
|
57
|
-
/** Parse location.hash into { view, subId } — port of client-main.js:45-49. */
|
|
61
|
+
/** Parse location.hash into { view, subId, filters } — port of client-main.js:45-49. */
|
|
58
62
|
function parseHash() {
|
|
59
63
|
const raw = location.hash.slice(1) || 'overview';
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
64
|
+
// Strip ?query suffix before routing so it never leaks into view/subId.
|
|
65
|
+
const qIdx = raw.indexOf('?');
|
|
66
|
+
const path = qIdx === -1 ? raw : raw.slice(0, qIdx);
|
|
67
|
+
const slash = path.indexOf('/');
|
|
68
|
+
const view = slash === -1 ? path : path.slice(0, slash);
|
|
69
|
+
// subId must not include the ?query portion.
|
|
70
|
+
const subId = slash === -1 ? null : path.slice(slash + 1);
|
|
63
71
|
// #263: unknown hash falls back to overview
|
|
64
72
|
const resolvedView = ALL_VIEWS.includes(view) ? view : 'overview';
|
|
65
|
-
|
|
73
|
+
const filters = parseFilters(location.hash);
|
|
74
|
+
return { view: resolvedView, subId, filters };
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
/** Full-width banner shown when /api/state polling is failing. */
|
|
69
78
|
function OfflineBanner({ offline }) {
|
|
70
79
|
if (!offline) return null;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
return html`<div class="offline-banner" role="alert">⚠ Dashboard offline — retrying every 30s…</div>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Dismissible banner shown when .rcode/state.json failed to parse. */
|
|
84
|
+
function ParseErrorBanner({ error, dismissed }) {
|
|
85
|
+
if (!error || dismissed) return null;
|
|
86
|
+
return html`
|
|
87
|
+
<div class="parse-error-banner" role="alert">
|
|
88
|
+
<span>⚠ .rcode/state.json is corrupted — data shown may be stale or empty (${error})</span>
|
|
89
|
+
<button class="banner-dismiss" aria-label="Dismiss"
|
|
90
|
+
onClick=${() => setState({ parseErrorDismissed: true })}>✕</button>
|
|
91
|
+
</div>
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Close the mobile slide-in sidebar (no-op on desktop where it is static). */
|
|
96
|
+
function closeMobileSidebar() {
|
|
97
|
+
const sidebar = document.querySelector('.sidebar');
|
|
98
|
+
const backdrop = document.getElementById('sidebar-backdrop');
|
|
99
|
+
if (sidebar) sidebar.classList.remove('open');
|
|
100
|
+
if (backdrop) backdrop.classList.remove('show');
|
|
75
101
|
}
|
|
76
102
|
|
|
77
103
|
/** Thin IDE-style status bar: project path · rcode version · last refresh. */
|
|
78
104
|
function StatusBar({ projectRoot, projectName, version, updatedAgo, offline, refreshing }) {
|
|
79
|
-
const bar = 'display:flex;align-items:center;gap:var(--space-4);height:24px;'
|
|
80
|
-
+ 'padding:0 var(--space-4);background:var(--bg-elev-1);'
|
|
81
|
-
+ 'border-top:1px solid var(--border-subtle);font-family:var(--font-mono);'
|
|
82
|
-
+ 'font-size:var(--text-2xs);color:var(--text-muted);white-space:nowrap;overflow:hidden;';
|
|
83
|
-
const dot = 'width:6px;height:6px;border-radius:50%;flex-shrink:0;background:'
|
|
84
|
-
+ (offline ? 'var(--red,#eb5757)' : 'var(--accent-green)') + ';'
|
|
85
|
-
+ (refreshing ? 'animation:pulse-dot 1s ease-in-out infinite;' : '');
|
|
86
105
|
const path = projectRoot || projectName || 'no project';
|
|
106
|
+
const dotCls = 'statusbar-dot'
|
|
107
|
+
+ (offline ? ' statusbar-dot--offline' : '')
|
|
108
|
+
+ (refreshing ? ' statusbar-dot--busy' : '');
|
|
87
109
|
return html`
|
|
88
|
-
<footer
|
|
89
|
-
<span
|
|
90
|
-
<span
|
|
91
|
-
<span
|
|
110
|
+
<footer class="statusbar">
|
|
111
|
+
<span class=${dotCls}></span>
|
|
112
|
+
<span class="statusbar-path" title=${path}>${path}</span>
|
|
113
|
+
<span class="statusbar-version">rcode v${version || '?'}</span>
|
|
92
114
|
<span>${offline ? 'offline' : refreshing ? 'syncing…' : 'updated ' + updatedAgo}</span>
|
|
93
115
|
</footer>
|
|
94
116
|
`;
|
|
@@ -97,10 +119,13 @@ function StatusBar({ projectRoot, projectName, version, updatedAgo, offline, ref
|
|
|
97
119
|
/** Root App component. No props needed — reads everything from the store. */
|
|
98
120
|
export function App() {
|
|
99
121
|
// ---- Router state ----
|
|
100
|
-
const [{ view, subId }, setRoute] = useState(parseHash);
|
|
122
|
+
const [{ view, subId, filters }, setRoute] = useState(parseHash);
|
|
101
123
|
|
|
102
124
|
useEffect(() => {
|
|
103
|
-
function onHashChange() {
|
|
125
|
+
function onHashChange() {
|
|
126
|
+
setRoute(parseHash());
|
|
127
|
+
closeMobileSidebar(); // navigating from the mobile nav should reveal the view
|
|
128
|
+
}
|
|
104
129
|
window.addEventListener('hashchange', onHashChange);
|
|
105
130
|
return () => window.removeEventListener('hashchange', onHashChange);
|
|
106
131
|
}, []);
|
|
@@ -129,13 +154,13 @@ export function App() {
|
|
|
129
154
|
}, [theme]);
|
|
130
155
|
|
|
131
156
|
// ---- Sidebar collapse ----
|
|
157
|
+
// Class names match the mobile CSS contract: .sidebar.open + #sidebar-backdrop.show
|
|
132
158
|
const toggleSidebar = useCallback(() => {
|
|
133
159
|
const sidebar = document.querySelector('.sidebar');
|
|
134
160
|
const backdrop = document.getElementById('sidebar-backdrop');
|
|
135
161
|
if (!sidebar) return;
|
|
136
|
-
const open = sidebar.classList.toggle('
|
|
137
|
-
if (backdrop) backdrop.classList.toggle('
|
|
138
|
-
document.body.classList.toggle('sidebar-visible', open);
|
|
162
|
+
const open = sidebar.classList.toggle('open');
|
|
163
|
+
if (backdrop) backdrop.classList.toggle('show', open);
|
|
139
164
|
}, []);
|
|
140
165
|
|
|
141
166
|
// ---- Updated-ago display ----
|
|
@@ -159,18 +184,43 @@ export function App() {
|
|
|
159
184
|
const r = await fetch('/api/state');
|
|
160
185
|
if (!r.ok) { setState({ refreshing: false, offline: true }); return; }
|
|
161
186
|
const newState = await r.json();
|
|
187
|
+
// The server's scan cache keeps lastScanned stable while nothing on
|
|
188
|
+
// disk changed — same stamp means identical data, so skip the patch
|
|
189
|
+
// entirely instead of committing fresh object identities that would
|
|
190
|
+
// re-render every subscribed component.
|
|
191
|
+
if (lastScannedRef.current && lastScannedRef.current === newState.lastScanned) {
|
|
192
|
+
scanTimeRef.current = Date.now();
|
|
193
|
+
setUpdatedAgo('just now');
|
|
194
|
+
setState({ refreshing: false, offline: false });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
162
197
|
lastScannedRef.current = newState.lastScanned;
|
|
163
198
|
scanTimeRef.current = Date.now();
|
|
164
199
|
setUpdatedAgo('just now');
|
|
165
|
-
const patch = {
|
|
200
|
+
const patch = {
|
|
201
|
+
refreshing: false, offline: false, lastRefresh: Date.now(),
|
|
202
|
+
// Surface state.json corruption (§1.4) — also clears the banner once fixed.
|
|
203
|
+
rawParseError: newState.rawParseError || null,
|
|
204
|
+
};
|
|
205
|
+
// Redesign contract slices (DATA-CONTRACT.md) — derived server-side and
|
|
206
|
+
// returned under newState.dashboard. Keep them fresh on every poll.
|
|
207
|
+
const d = newState.dashboard || {};
|
|
208
|
+
Object.assign(patch, {
|
|
209
|
+
initialized: newState.exists !== false,
|
|
210
|
+
project: d.project || null,
|
|
211
|
+
progress: d.progress || null,
|
|
212
|
+
timeline: d.timeline || null,
|
|
213
|
+
tasks: d.tasks || null,
|
|
214
|
+
health: d.health || null,
|
|
215
|
+
});
|
|
166
216
|
if (newState.raw) {
|
|
167
217
|
Object.assign(patch, {
|
|
168
|
-
phases: newState.phaseTree
|
|
218
|
+
phases: d.phases || newState.phaseTree || newState.raw.phases || [],
|
|
169
219
|
milestone: newState.raw.milestone || '',
|
|
170
|
-
currentPhase: newState.raw.current_phase
|
|
220
|
+
currentPhase: d.currentPhase || newState.raw.current_phase || null,
|
|
171
221
|
currentSprint: newState.raw.current_sprint || null,
|
|
172
|
-
decisions: newState.raw.decisions
|
|
173
|
-
blockers: newState.raw.blockers
|
|
222
|
+
decisions: d.decisions || newState.raw.decisions || [],
|
|
223
|
+
blockers: d.blockers || newState.raw.blockers || [],
|
|
174
224
|
council_sessions: newState.raw.council_sessions || [],
|
|
175
225
|
last_session: newState.raw.last_session || null,
|
|
176
226
|
});
|
|
@@ -200,6 +250,20 @@ export function App() {
|
|
|
200
250
|
startSessionsPoll();
|
|
201
251
|
}, []);
|
|
202
252
|
|
|
253
|
+
// ---- Command palette ----
|
|
254
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
255
|
+
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
function onKeyDown(e) {
|
|
258
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
setPaletteOpen(o => !o);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
window.addEventListener('keydown', onKeyDown);
|
|
264
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
203
267
|
// ---- View rendering ----
|
|
204
268
|
const PreactView = PREACT_VIEWS[view] || null;
|
|
205
269
|
|
|
@@ -207,15 +271,9 @@ export function App() {
|
|
|
207
271
|
<div class="app-shell">
|
|
208
272
|
<${Sidebar} activeView=${view} projectName=${storeState.projectName || ''} />
|
|
209
273
|
|
|
210
|
-
<div id="sidebar-backdrop" onClick=${
|
|
211
|
-
const sidebar = document.querySelector('.sidebar');
|
|
212
|
-
const backdrop = document.getElementById('sidebar-backdrop');
|
|
213
|
-
if (sidebar) sidebar.classList.remove('sidebar-open');
|
|
214
|
-
if (backdrop) backdrop.classList.remove('active');
|
|
215
|
-
document.body.classList.remove('sidebar-visible');
|
|
216
|
-
}}></div>
|
|
274
|
+
<div id="sidebar-backdrop" onClick=${closeMobileSidebar}></div>
|
|
217
275
|
|
|
218
|
-
<div class="content-area" id="main-content"
|
|
276
|
+
<div class="content-area" id="main-content">
|
|
219
277
|
<${Topbar}
|
|
220
278
|
projectName=${storeState.projectName || ''}
|
|
221
279
|
updatedAgo=${updatedAgo}
|
|
@@ -228,7 +286,11 @@ export function App() {
|
|
|
228
286
|
|
|
229
287
|
<div class="main-scroll" id="main-scroll">
|
|
230
288
|
<${OfflineBanner} offline=${storeState.offline} />
|
|
231
|
-
|
|
289
|
+
<${ParseErrorBanner}
|
|
290
|
+
error=${storeState.rawParseError}
|
|
291
|
+
dismissed=${storeState.parseErrorDismissed}
|
|
292
|
+
/>
|
|
293
|
+
${PreactView ? html`<${PreactView} subId=${subId} filters=${filters} />` : null}
|
|
232
294
|
</div>
|
|
233
295
|
|
|
234
296
|
<${StatusBar}
|
|
@@ -243,6 +305,9 @@ export function App() {
|
|
|
243
305
|
|
|
244
306
|
<${XtermPanel} />
|
|
245
307
|
<${OrchPanel} />
|
|
308
|
+
<${BlockedToasts} />
|
|
309
|
+
<${RunnerPicker} />
|
|
310
|
+
<${CommandPalette} open=${paletteOpen} onClose=${() => setPaletteOpen(false)} />
|
|
246
311
|
</div>
|
|
247
312
|
`;
|
|
248
313
|
}
|