@hanzlaa/rcode 4.1.2 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/cli/install.js +176 -13
  2. package/cli/lib/config.cjs +4 -2
  3. package/cli/lib/fsutil.cjs +13 -2
  4. package/cli/lib/homedir.cjs +21 -0
  5. package/cli/lib/schemas.cjs +6 -1
  6. package/cli/nuke.js +13 -8
  7. package/cli/postinstall.js +14 -4
  8. package/cli/rcode-slash-router.cjs +118 -0
  9. package/cli/uninstall.js +59 -1
  10. package/cli/update.js +10 -5
  11. package/dist/rcode.js +234 -230
  12. package/package.json +1 -1
  13. package/server/dashboard.js +26 -7
  14. package/server/lib/api.js +62 -4
  15. package/server/lib/html/client/agents-data.js +22 -18
  16. package/server/lib/html/client/app.js +3 -0
  17. package/server/lib/html/client/components/AgentCard.js +127 -0
  18. package/server/lib/html/client/components/App.js +104 -39
  19. package/server/lib/html/client/components/CommandPalette.js +133 -0
  20. package/server/lib/html/client/components/FileReader.js +116 -0
  21. package/server/lib/html/client/components/FilterChips.js +94 -0
  22. package/server/lib/html/client/components/NotifyCenter.js +117 -0
  23. package/server/lib/html/client/components/OrchPanel.js +80 -52
  24. package/server/lib/html/client/components/PhaseGraph.js +300 -0
  25. package/server/lib/html/client/components/RejectDialog.js +78 -0
  26. package/server/lib/html/client/components/RunnerPicker.js +190 -0
  27. package/server/lib/html/client/components/Sidebar.js +106 -61
  28. package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
  29. package/server/lib/html/client/components/TaskPipeline.js +83 -0
  30. package/server/lib/html/client/components/Topbar.js +86 -39
  31. package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
  32. package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
  33. package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
  34. package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
  35. package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
  36. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
  37. package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
  38. package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
  39. package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
  40. package/server/lib/html/client/components/shared.js +47 -11
  41. package/server/lib/html/client/filter-state.js +72 -0
  42. package/server/lib/html/client/icons-client.js +7 -0
  43. package/server/lib/html/client/notify.js +75 -0
  44. package/server/lib/html/client/orchestrator.js +168 -41
  45. package/server/lib/html/client/preact.js +13 -8
  46. package/server/lib/html/client/store.js +70 -6
  47. package/server/lib/html/client/util.js +78 -0
  48. package/server/lib/html/client/vendor/htm.js +1 -0
  49. package/server/lib/html/client/vendor/preact-hooks.js +2 -0
  50. package/server/lib/html/client/vendor/preact.js +2 -0
  51. package/server/lib/html/client/views/AgentsView.js +144 -51
  52. package/server/lib/html/client/views/FilesView.js +20 -103
  53. package/server/lib/html/client/views/KanbanView.js +40 -21
  54. package/server/lib/html/client/views/MemoryView.js +26 -9
  55. package/server/lib/html/client/views/MilestonesView.js +4 -4
  56. package/server/lib/html/client/views/OrchestrationView.js +154 -19
  57. package/server/lib/html/client/views/OverviewView.js +47 -239
  58. package/server/lib/html/client/views/PhasesView.js +50 -6
  59. package/server/lib/html/client/views/RoadmapView.js +6 -3
  60. package/server/lib/html/client/views/SprintsView.js +50 -6
  61. package/server/lib/html/client/views/TasksView.js +4 -3
  62. package/server/lib/html/client.js +21 -4
  63. package/server/lib/html/css.js +2761 -8
  64. package/server/lib/html/icons.js +7 -0
  65. package/server/lib/html/shell.js +10 -3
  66. package/server/lib/scanner.js +376 -39
  67. package/server/orchestrator.js +329 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "4.1.2",
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": {
@@ -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 exactly one optional subdirectory (e.g. components/App.js, views/Foo.js)
108
- // while still rejecting traversal attempts. The regex blocks `..`, encoded
109
- // separators, and anything other than word chars, dots, hyphens, and one `/`.
110
- if (!/^(?:[\w.-]+\/)?[\w.-]+\.js$/.test(name)) { res.writeHead(404); res.end('Not found'); return; }
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
- res.end(JSON.stringify(state, null, 2));
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, null, 2));
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, null, 2));
202
+ res.end(JSON.stringify(memory));
202
203
  }
203
204
 
204
- module.exports = { handleApiState, handleApiFiles, handleApiFile, handleApiHierarchy, handleApiMemory };
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
- const slash = raw.indexOf('/');
61
- const view = slash === -1 ? raw : raw.slice(0, slash);
62
- const subId = slash === -1 ? null : raw.slice(slash + 1);
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
- return { view: resolvedView, subId };
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
- const s = 'display:flex;align-items:center;gap:var(--space-2);'
72
- + 'padding:var(--space-2) var(--space-4);background:var(--red,#eb5757);'
73
- + 'color:#fff;font-size:var(--text-sm);font-weight:600;';
74
- return html`<div style=${s}>⚠ Dashboard offline retrying every 30s…</div>`;
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 style=${bar}>
89
- <span style=${dot}></span>
90
- <span style="overflow:hidden;text-overflow:ellipsis;" title=${path}>${path}</span>
91
- <span style="margin-left:auto;">rcode v${version || '?'}</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() { setRoute(parseHash()); }
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('sidebar-open');
137
- if (backdrop) backdrop.classList.toggle('active', open);
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 = { refreshing: false, offline: false, lastRefresh: Date.now() };
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 || newState.raw.phases || [],
218
+ phases: d.phases || newState.phaseTree || newState.raw.phases || [],
169
219
  milestone: newState.raw.milestone || '',
170
- currentPhase: newState.raw.current_phase || null,
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" style="grid-template-rows:44px 1fr auto;">
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
- ${PreactView ? html`<${PreactView} subId=${subId} />` : null}
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
  }