@hanzlaa/rcode 4.5.0 → 4.7.0

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