@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.
- package/README.md +47 -14
- package/package.json +1 -1
- package/rcode/bin/rcode-tools.cjs +1 -0
- package/rcode/references/agent-shared-rules.md +6 -0
- package/rcode/references/executor-playbook.md +1 -1
- package/rcode/references/planner-playbook.md +1 -0
- package/rcode/workflows/init.md +13 -0
- package/rcode/workflows/new-project-create-roadmap.md +18 -28
- package/server/lib/html/client/components/App.js +15 -1
- package/server/lib/html/client/components/PhaseGraph.js +20 -12
- package/server/lib/html/client/components/Sidebar.js +1 -0
- package/server/lib/html/client/components/Topbar.js +3 -3
- package/server/lib/html/client/components/dashboard/Blockers.js +3 -3
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +4 -2
- package/server/lib/html/client/components/dashboard/InProgress.js +4 -3
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +11 -6
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +4 -3
- package/server/lib/html/client/components/shared.js +111 -2
- package/server/lib/html/client/store.js +47 -0
- package/server/lib/html/client/util.js +1 -22
- package/server/lib/html/client/views/BacklogView.js +44 -0
- package/server/lib/html/client/views/DecisionsView.js +4 -3
- package/server/lib/html/client/views/FilesView.js +47 -34
- package/server/lib/html/client/views/KanbanView.js +8 -0
- package/server/lib/html/client/views/PhasesView.js +8 -3
- package/server/lib/html/client/views/SprintsView.js +9 -0
- package/server/lib/html/client.js +1 -0
- package/server/lib/html/css.js +96 -19
- 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,
|
|
24
|
+
function FileEntry({ file, onSelect, isSelected }) {
|
|
19
25
|
return html`
|
|
20
26
|
<div
|
|
21
|
-
class=${'
|
|
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
|
-
|
|
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"
|
|
41
|
-
<div
|
|
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
|
-
${
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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"
|
|
73
|
-
<div
|
|
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">
|
|
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"
|
|
147
|
+
? html`<div class="empty">Loading…</div>`
|
|
135
148
|
: groups.length === 0
|
|
136
|
-
? html`<div class="empty"
|
|
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
|
-
|
|
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
|
-
|
|
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 || [],
|