@hanzlaa/rcode 4.6.0 → 4.7.1

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 (29) hide show
  1. package/README.md +47 -14
  2. package/package.json +1 -1
  3. package/rcode/bin/rcode-tools.cjs +1 -0
  4. package/rcode/references/agent-shared-rules.md +6 -0
  5. package/rcode/references/executor-playbook.md +1 -1
  6. package/rcode/references/planner-playbook.md +1 -0
  7. package/rcode/workflows/init.md +13 -0
  8. package/rcode/workflows/new-project-create-roadmap.md +18 -28
  9. package/server/lib/html/client/components/App.js +15 -1
  10. package/server/lib/html/client/components/PhaseGraph.js +20 -12
  11. package/server/lib/html/client/components/Sidebar.js +1 -0
  12. package/server/lib/html/client/components/Topbar.js +3 -3
  13. package/server/lib/html/client/components/dashboard/Blockers.js +3 -3
  14. package/server/lib/html/client/components/dashboard/CompletedTasks.js +4 -2
  15. package/server/lib/html/client/components/dashboard/InProgress.js +4 -3
  16. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +11 -6
  17. package/server/lib/html/client/components/dashboard/RecentDecisions.js +4 -3
  18. package/server/lib/html/client/components/shared.js +111 -2
  19. package/server/lib/html/client/store.js +47 -0
  20. package/server/lib/html/client/util.js +1 -22
  21. package/server/lib/html/client/views/BacklogView.js +44 -0
  22. package/server/lib/html/client/views/DecisionsView.js +4 -3
  23. package/server/lib/html/client/views/FilesView.js +47 -34
  24. package/server/lib/html/client/views/KanbanView.js +8 -0
  25. package/server/lib/html/client/views/PhasesView.js +8 -3
  26. package/server/lib/html/client/views/SprintsView.js +9 -0
  27. package/server/lib/html/client.js +1 -0
  28. package/server/lib/html/css.js +96 -19
  29. package/server/lib/scanner.js +21 -6
@@ -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
+ }
@@ -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 };
@@ -75,7 +75,7 @@ export function allSprints(phases) {
75
75
  export function allTasks(phases) {
76
76
  return (phases || []).flatMap(p =>
77
77
  (p.sprints || []).flatMap(s =>
78
- (s.stories || []).map(t => Object.assign({}, t, { sprintId: s.id, phaseId: p.id, phaseName: p.name }))
78
+ (s.stories || []).map(t => Object.assign({}, t, { sprintId: s.id, phaseId: p.id, phaseName: p.name, file: s.file || null }))
79
79
  )
80
80
  );
81
81
  }
@@ -139,27 +139,6 @@ export function sessionChip(status) {
139
139
  return { cls, label: status };
140
140
  }
141
141
 
142
- /**
143
- * Props for a clickable card row that navigates to a hash route.
144
- * Spread onto a list row (`<li ...${rowLink('tasks')}>`) to make it act like
145
- * a link: pointer + keyboard activation and an accessible role. Pair with the
146
- * `ovr-link` class for the hover/focus affordance.
147
- *
148
- * @param {string} hash — target hash route without the leading '#'.
149
- * @returns {object} Preact props (role, tabindex, onClick, onKeyDown).
150
- */
151
- export function rowLink(hash) {
152
- const go = () => { location.hash = hash; };
153
- return {
154
- role: 'link',
155
- tabindex: 0,
156
- onClick: go,
157
- onKeyDown: (e) => {
158
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); }
159
- },
160
- };
161
- }
162
-
163
142
  /**
164
143
  * Human-readable elapsed time since an ISO timestamp.
165
144
  * Ported from _orchElapsed() in client-main.js.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * BacklogView — Preact component.
3
+ *
4
+ * Lists phases that haven't started yet (state.dashboard.backlog, derived
5
+ * server-side in scanner.js from phases with state === 'todo'). Each row
6
+ * links to that phase's detail page — same navigation PhaseCard/SprintCard
7
+ * already use elsewhere, no new routing concept.
8
+ */
9
+
10
+ import { html } from '../preact.js';
11
+ import { useStore } from '../store.js';
12
+ import { pressable } from '../components/shared.js';
13
+ import { Icon } from '../icons-client.js';
14
+
15
+ export function BacklogView() {
16
+ const S = useStore();
17
+ const backlog = S.backlog || [];
18
+
19
+ return html`
20
+ <div id="view-backlog" class="view active">
21
+ <div class="view-title">Backlog</div>
22
+ ${backlog.length === 0
23
+ ? html`
24
+ <div class="empty">
25
+ No phases waiting in the backlog.
26
+ <div class="empty-action">Run /rcode-add-phase to queue up new work</div>
27
+ </div>
28
+ `
29
+ : html`
30
+ <div class="phase-list">
31
+ ${backlog.map(p => html`
32
+ <div key=${p.id} class="item item-clickable"
33
+ ...${pressable(() => { location.hash = 'phases/' + p.id; })}>
34
+ <div class="item-title">
35
+ <${Icon} name="clipboard-list" size=${16}/> Phase ${p.id} — ${p.name}
36
+ </div>
37
+ ${p.range ? html`<div class="item-meta">${p.range}</div>` : null}
38
+ </div>
39
+ `)}
40
+ </div>
41
+ `}
42
+ </div>
43
+ `;
44
+ }
@@ -7,9 +7,9 @@
7
7
  */
8
8
 
9
9
  import { html, useState } from '../preact.js';
10
- import { useStore } from '../store.js';
10
+ import { useStore, openDecisionViewer } from '../store.js';
11
11
  import { humanDate } from '../util.js';
12
- import { CmdHint, CmdHints, showToast } from '../components/shared.js';
12
+ import { CmdHint, CmdHints, showToast, pressable } from '../components/shared.js';
13
13
 
14
14
  const CMD_HINTS = [
15
15
  ['/rcode-council', 'Convene the council for a new decision'],
@@ -83,8 +83,9 @@ export function DecisionsView() {
83
83
  const rationale = (typeof d === 'object' && d.rationale)
84
84
  ? html`<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">${d.rationale}</div>`
85
85
  : null;
86
+ const decisionObj = typeof d === 'object' ? d : { title: d };
86
87
  return html`
87
- <div key=${i} class="item">
88
+ <div key=${i} class="item item-clickable" ...${pressable(() => openDecisionViewer(decisionObj))}>
88
89
  <div class="item-title">${title}${dateInfo}</div>
89
90
  <div class="item-meta">${phaseInfo}</div>
90
91
  ${rationale}
@@ -10,20 +10,27 @@
10
10
  * clears the field so subsequent renders don't re-trigger.
11
11
  */
12
12
 
13
- import { html, useState, useEffect } from '../preact.js';
13
+ import { html, useState, useEffect, useMemo } from '../preact.js';
14
14
  import { useStore, setState } from '../store.js';
15
15
  import { FileReader } from '../components/FileReader.js';
16
+ import { Icon } from '../icons-client.js';
17
+
18
+ // File-type icon — markdown gets its own glyph, everything else a plain file.
19
+ function fileIcon(label) {
20
+ return /\.md$/i.test(label || '') ? 'file-text' : 'file';
21
+ }
16
22
 
17
23
  // ---- File tree components ----
18
- function FileEntry({ file, extraText, onSelect, isSelected }) {
24
+ function FileEntry({ file, onSelect, isSelected }) {
19
25
  return html`
20
26
  <div
21
- class=${'item item-clickable inline-file-entry' + (isSelected ? ' selected' : '')}
27
+ class=${'inline-file-entry' + (isSelected ? ' selected' : '')}
22
28
  data-path=${file.path}
29
+ title=${file.path}
23
30
  onClick=${() => onSelect(file)}
24
- style="padding:var(--space-2) var(--space-3);font-family:'SF Mono',Monaco,Consolas,monospace;font-size:var(--text-xs);"
25
31
  >
26
- ${file.label}
32
+ <${Icon} name=${fileIcon(file.label)} size=${13}/>
33
+ <span class="inline-file-label">${file.label}</span>
27
34
  </div>
28
35
  `;
29
36
  }
@@ -36,31 +43,31 @@ function FileGroup({ group, onSelect, selectedPath, filter }) {
36
43
  }
37
44
 
38
45
  if (group.subGroups) {
46
+ const subGroupsVisible = group.subGroups
47
+ .map(sg => ({ sg, visible: sg.files.filter(f => matchesFilter(f, sg.subGroup)) }))
48
+ .filter(x => x.visible.length);
49
+ if (!subGroupsVisible.length) return null;
50
+ const total = subGroupsVisible.reduce((n, x) => n + x.visible.length, 0);
39
51
  return html`
40
- <div class="inline-file-group" style="margin-bottom:var(--space-3);">
41
- <div style="font-size:var(--text-xs);font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.07em;padding:var(--space-1) var(--space-3);">
42
- ${group.group}
52
+ <div class="inline-file-group">
53
+ <div class="inline-file-group-title">
54
+ ${group.group} <span class="inline-file-group-count">${total}</span>
43
55
  </div>
44
- ${group.subGroups.map(sg => {
45
- const visible = sg.files.filter(f => matchesFilter(f, sg.subGroup));
46
- if (!visible.length) return null;
47
- return html`
48
- <details class="inline-subgroup" open style="margin-left:var(--space-2);margin-bottom:var(--space-1);">
49
- <summary style="font-size:var(--text-xs);font-weight:500;color:var(--text-secondary);cursor:pointer;padding:var(--space-1) var(--space-3);user-select:none;">
50
- ${sg.subGroup} <span style="color:var(--text-muted);font-weight:400;">(${visible.length})</span>
51
- </summary>
52
- ${visible.map(f => html`
53
- <${FileEntry}
54
- key=${f.path}
55
- file=${f}
56
- extraText=${sg.subGroup}
57
- onSelect=${onSelect}
58
- isSelected=${selectedPath === f.path}
59
- />
60
- `)}
61
- </details>
62
- `;
63
- })}
56
+ ${subGroupsVisible.map(({ sg, visible }) => html`
57
+ <details class="inline-subgroup" open>
58
+ <summary class="inline-subgroup-summary">
59
+ ${sg.subGroup} <span class="inline-file-group-count">${visible.length}</span>
60
+ </summary>
61
+ ${visible.map(f => html`
62
+ <${FileEntry}
63
+ key=${f.path}
64
+ file=${f}
65
+ onSelect=${onSelect}
66
+ isSelected=${selectedPath === f.path}
67
+ />
68
+ `)}
69
+ </details>
70
+ `)}
64
71
  </div>
65
72
  `;
66
73
  }
@@ -69,9 +76,9 @@ function FileGroup({ group, onSelect, selectedPath, filter }) {
69
76
  const visible = group.files.filter(f => matchesFilter(f, ''));
70
77
  if (!visible.length) return null;
71
78
  return html`
72
- <div class="inline-file-group" style="margin-bottom:var(--space-3);">
73
- <div style="font-size:var(--text-xs);font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.07em;padding:var(--space-1) var(--space-3);">
74
- ${group.group}
79
+ <div class="inline-file-group">
80
+ <div class="inline-file-group-title">
81
+ ${group.group} <span class="inline-file-group-count">${visible.length}</span>
75
82
  </div>
76
83
  ${visible.map(f => html`
77
84
  <${FileEntry}
@@ -116,9 +123,15 @@ export function FilesView() {
116
123
  setState({ requestedFile: null });
117
124
  }, [requestedFile]);
118
125
 
126
+ const total = useMemo(() => groups.reduce((n, g) =>
127
+ n + (g.files ? g.files.length : (g.subGroups || []).reduce((m, sg) => m + sg.files.length, 0)), 0),
128
+ [groups]);
129
+
119
130
  return html`
120
131
  <div class="view active" id="view-files">
121
- <div class="view-title">Files</div>
132
+ <div class="view-title">
133
+ Files ${total > 0 ? html`<span class="inline-file-total">${total}</span>` : null}
134
+ </div>
122
135
  <div id="file-list-inline">
123
136
  <div class="filter-bar">
124
137
  <input
@@ -131,9 +144,9 @@ export function FilesView() {
131
144
  </div>
132
145
  <div id="inline-file-items" class="phase-list">
133
146
  ${loading
134
- ? html`<div class="empty" style="margin:16px;">Loading…</div>`
147
+ ? html`<div class="empty">Loading…</div>`
135
148
  : groups.length === 0
136
- ? html`<div class="empty" style="margin:16px;">No files found.</div>`
149
+ ? html`<div class="empty">No files found.</div>`
137
150
  : groups.map(g => html`
138
151
  <${FileGroup}
139
152
  key=${g.group}
@@ -11,6 +11,7 @@ import { html, useState, useCallback } from '../preact.js';
11
11
  import { useStore, refresh } from '../store.js';
12
12
  import { allTasks, currentPhaseName } from '../util.js';
13
13
  import { stopStory, openOrchPanel, openTermPanel, setTaskStatus } from '../orchestrator.js';
14
+ import { openFileViewer } from '../store.js';
14
15
  import { openRunnerPicker } from '../components/RunnerPicker.js';
15
16
  import { showToast } from '../components/shared.js';
16
17
 
@@ -66,6 +67,10 @@ function KanbanCard({ task, col, live, orchDown, onDragStart, onDragEnd }) {
66
67
  e.stopPropagation();
67
68
  openOrchPanel(sid);
68
69
  }
70
+ function handleViewFile(e) {
71
+ e.stopPropagation();
72
+ openFileViewer(task.file, task.title || sid);
73
+ }
69
74
 
70
75
  return html`
71
76
  <div
@@ -92,6 +97,9 @@ function KanbanCard({ task, col, live, orchDown, onDragStart, onDragEnd }) {
92
97
  ` : null}
93
98
  ${sid ? html`
94
99
  <div class="kanban-card-actions">
100
+ ${task.file ? html`
101
+ <button class="kanban-view-btn" title=${'View ' + task.file} onClick=${handleViewFile}>📄 File</button>
102
+ ` : null}
95
103
  ${canRun ? html`
96
104
  <button class="kanban-run-btn" disabled=${orchDown}
97
105
  title=${orchDown ? 'Orchestrator unreachable' : 'Run ' + sid}
@@ -14,6 +14,7 @@ import {
14
14
  Chip, ProgressBar, Breadcrumb, CmdHints, RunningBadge, SprintCard, PhaseCard,
15
15
  } from '../components/shared.js';
16
16
  import { openTermPanel, runningInPhase } from '../orchestrator.js';
17
+ import { openFileViewer } from '../store.js';
17
18
  import { openRunnerPicker } from '../components/RunnerPicker.js';
18
19
  import { Icon } from '../icons-client.js';
19
20
  import { StatusSummaryBar } from '../components/StatusSummaryBar.js';
@@ -71,10 +72,12 @@ function PhaseDetail({ phase: p, S }) {
71
72
  e.stopPropagation();
72
73
  openTermPanel('phase-' + p.id, 'Phase ' + p.id);
73
74
  }
75
+ // First sprint that has a resolved SPRINT.md path — the phase-level "view
76
+ // plan" entry point; each SprintCard below also gets its own precise button.
77
+ const planFile = (sps.find(s => s.file) || {}).file || null;
74
78
  function handleViewPlan(e) {
75
79
  e.stopPropagation();
76
- // Navigate to files view viewPlanFile was a legacy DOM function
77
- window.location.hash = 'files';
80
+ openFileViewer(planFile, 'Phase ' + p.id + ' plan');
78
81
  }
79
82
 
80
83
  return html`
@@ -99,7 +102,9 @@ function PhaseDetail({ phase: p, S }) {
99
102
  <div class="term-action-bar">
100
103
  <button class="term-run-btn" onClick=${handleRun}>▶ Run Phase</button>
101
104
  <button class="term-run-btn outline" onClick=${handleTerm}><${Icon} name="monitor" size=${14}/> Terminal</button>
102
- <button class="back-btn" onClick=${handleViewPlan}><${Icon} name="file-text" size=${14}/> View plan file →</button>
105
+ ${planFile ? html`
106
+ <button class="back-btn" onClick=${handleViewPlan}><${Icon} name="file-text" size=${14}/> View plan file →</button>
107
+ ` : null}
103
108
  </div>
104
109
  <${VelocityBars} sprints=${sps}/>
105
110
  <div class="view-title" style="margin-top:var(--space-6)">Sprints</div>
@@ -15,6 +15,7 @@ import {
15
15
  Chip, ProgressBar, Breadcrumb, CmdHints, RunningBadge, SprintCard, TaskCard,
16
16
  } from '../components/shared.js';
17
17
  import { openTermPanel, runningInSprint } from '../orchestrator.js';
18
+ import { openFileViewer } from '../store.js';
18
19
  import { openRunnerPicker } from '../components/RunnerPicker.js';
19
20
  import { Icon } from '../icons-client.js';
20
21
  import { StatusSummaryBar } from '../components/StatusSummaryBar.js';
@@ -37,6 +38,7 @@ function SprintDetail({ sprint: s, S }) {
37
38
  sprintGoal: s.goal || '',
38
39
  phaseId: s.phaseId,
39
40
  phaseName: s.phaseName,
41
+ file: s.file || null,
40
42
  }),
41
43
  );
42
44
  const done = stories.filter(t => t.status === 'done' || t.status === 'completed').length;
@@ -60,6 +62,10 @@ function SprintDetail({ sprint: s, S }) {
60
62
  e.stopPropagation();
61
63
  openTermPanel('sprint-' + s.id, 'Sprint ' + s.id);
62
64
  }
65
+ function handleViewPlan(e) {
66
+ e.stopPropagation();
67
+ openFileViewer(s.file, 'Sprint ' + s.id);
68
+ }
63
69
 
64
70
  return html`
65
71
  <div>
@@ -88,6 +94,9 @@ function SprintDetail({ sprint: s, S }) {
88
94
  <div class="term-action-bar">
89
95
  <button class="term-run-btn" onClick=${handleRun}>▶ Run Sprint</button>
90
96
  <button class="term-run-btn outline" onClick=${handleTerm}><${Icon} name="monitor" size=${14}/> Terminal</button>
97
+ ${s.file ? html`
98
+ <button class="back-btn" onClick=${handleViewPlan}><${Icon} name="file-text" size=${14}/> View plan file →</button>
99
+ ` : null}
91
100
  </div>
92
101
  <div class="view-title" style="margin-top:var(--space-4)">Tasks</div>
93
102
  <div class="phase-list">
@@ -34,6 +34,7 @@ function clientState(state) {
34
34
  timeline: d.timeline || null,
35
35
  tasks: d.tasks || null,
36
36
  health: d.health || null,
37
+ backlog: d.backlog || [],
37
38
  // Colliding keys — the contract shapes are supersets, so legacy views and
38
39
  // the redesign read the same field. currentPhase becomes the contract object.
39
40
  phases: d.phases || state.phaseTree || state.raw?.phases || [],