@hanzlaa/rcode 3.5.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -1
- package/server/dashboard.js +105 -3
- package/server/lib/html/client/agents-data.js +27 -0
- package/server/lib/html/client/app.js +15 -0
- package/server/lib/html/client/components/App.js +211 -0
- package/server/lib/html/client/components/OrchPanel.js +293 -0
- package/server/lib/html/client/components/Sidebar.js +73 -0
- package/server/lib/html/client/components/Topbar.js +53 -0
- package/server/lib/html/client/components/XtermPanel.js +220 -0
- package/server/lib/html/client/components/shared.js +330 -0
- package/server/lib/html/client/icons-client.js +85 -0
- package/server/lib/html/client/orchestrator.js +279 -0
- package/server/lib/html/client/preact.js +34 -0
- package/server/lib/html/client/store.js +91 -0
- package/server/lib/html/client/util.js +186 -0
- package/server/lib/html/client/views/AgentsView.js +83 -0
- package/server/lib/html/client/views/DecisionsView.js +102 -0
- package/server/lib/html/client/views/FilesView.js +223 -0
- package/server/lib/html/client/views/KanbanView.js +236 -0
- package/server/lib/html/client/views/MemoryView.js +157 -0
- package/server/lib/html/client/views/MilestonesView.js +136 -0
- package/server/lib/html/client/views/OrchestrationView.js +167 -0
- package/server/lib/html/client/views/OverviewView.js +221 -0
- package/server/lib/html/client/views/PhasesView.js +184 -0
- package/server/lib/html/client/views/RoadmapView.js +238 -0
- package/server/lib/html/client/views/SprintsView.js +178 -0
- package/server/lib/html/client/views/TasksView.js +148 -0
- package/server/lib/html/client.js +41 -1775
- package/server/lib/html/css.js +264 -44
- package/server/lib/html/icons.js +68 -0
- package/server/lib/html/shell.js +9 -296
- package/server/lib/scanner.js +76 -0
- package/server/orchestrator.js +237 -313
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DecisionsView — Preact component.
|
|
3
|
+
*
|
|
4
|
+
* Ports renderDecisions() from client-main.js to a component.
|
|
5
|
+
* Filtering is local component state (useState), not the DOM filterItems hack.
|
|
6
|
+
* Reads decisions from the store via useStore().
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { html, useState } from '../preact.js';
|
|
10
|
+
import { useStore } from '../store.js';
|
|
11
|
+
import { humanDate } from '../util.js';
|
|
12
|
+
import { CmdHint, CmdHints, showToast } from '../components/shared.js';
|
|
13
|
+
|
|
14
|
+
const CMD_HINTS = [
|
|
15
|
+
['/rihal-council', 'Convene the council for a new decision'],
|
|
16
|
+
['/rihal-discuss [agent] "topic"', 'Discuss with a specific expert'],
|
|
17
|
+
['/rihal-decisions', 'View decision log'],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function DecisionsView() {
|
|
21
|
+
const S = useStore();
|
|
22
|
+
const decisions = S.decisions || [];
|
|
23
|
+
|
|
24
|
+
// Filter state — replaces the DOM filterItems hack
|
|
25
|
+
const [query, setQuery] = useState('');
|
|
26
|
+
const q = query.toLowerCase().trim();
|
|
27
|
+
|
|
28
|
+
if (!decisions.length) {
|
|
29
|
+
return html`
|
|
30
|
+
<div id="view-decisions" class="view active">
|
|
31
|
+
<div class="view-title">Decisions (ADRs)</div>
|
|
32
|
+
<div class="empty">
|
|
33
|
+
No decisions recorded yet.
|
|
34
|
+
<div class="empty-action">Decisions made during /rihal-council appear here</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Group by phase — same as client-main.js:renderDecisions
|
|
41
|
+
const grouped = {};
|
|
42
|
+
for (const d of decisions) {
|
|
43
|
+
const phase = (typeof d === 'object' ? d.phase : null) || 'General';
|
|
44
|
+
if (!grouped[phase]) grouped[phase] = [];
|
|
45
|
+
grouped[phase].push(d);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return html`
|
|
49
|
+
<div id="view-decisions" class="view active">
|
|
50
|
+
<div class="view-title">Decisions (ADRs)</div>
|
|
51
|
+
<div class="filter-bar">
|
|
52
|
+
<input
|
|
53
|
+
class="filter-input"
|
|
54
|
+
type="text"
|
|
55
|
+
placeholder="Filter…"
|
|
56
|
+
value=${query}
|
|
57
|
+
onInput=${e => setQuery(e.target.value)}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
<div id="decisions-inner">
|
|
61
|
+
${Object.entries(grouped).map(([phase, decs]) => {
|
|
62
|
+
const filteredDecs = q
|
|
63
|
+
? decs.filter(d => {
|
|
64
|
+
const title = typeof d === 'string' ? d : (d.title || d.summary || d.decision || JSON.stringify(d).slice(0, 80));
|
|
65
|
+
return String(title).toLowerCase().includes(q);
|
|
66
|
+
})
|
|
67
|
+
: decs;
|
|
68
|
+
if (!filteredDecs.length) return null;
|
|
69
|
+
return html`
|
|
70
|
+
<div key=${phase}>
|
|
71
|
+
<div class="memory-group-header">${phase}</div>
|
|
72
|
+
<div class="decision-list">
|
|
73
|
+
${filteredDecs.map((d, i) => {
|
|
74
|
+
const title = typeof d === 'string'
|
|
75
|
+
? d
|
|
76
|
+
: (d.title || d.summary || d.decision || JSON.stringify(d).slice(0, 80));
|
|
77
|
+
const dateInfo = (typeof d === 'object' && d.date)
|
|
78
|
+
? html`<span style="color:var(--text-muted);font-size:var(--text-xs);margin-left:8px;">${humanDate(d.date)}</span>`
|
|
79
|
+
: null;
|
|
80
|
+
const phaseInfo = (typeof d === 'object' && d.phase)
|
|
81
|
+
? html`<span class="tag">Phase ${d.phase}</span>`
|
|
82
|
+
: null;
|
|
83
|
+
const rationale = (typeof d === 'object' && d.rationale)
|
|
84
|
+
? html`<div style="color:var(--text-secondary);font-size:var(--text-sm);margin-top:4px;">${d.rationale}</div>`
|
|
85
|
+
: null;
|
|
86
|
+
return html`
|
|
87
|
+
<div key=${i} class="item">
|
|
88
|
+
<div class="item-title">${title}${dateInfo}</div>
|
|
89
|
+
<div class="item-meta">${phaseInfo}</div>
|
|
90
|
+
${rationale}
|
|
91
|
+
</div>
|
|
92
|
+
`;
|
|
93
|
+
})}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
`;
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
<${CmdHints} hints=${CMD_HINTS}/>
|
|
100
|
+
</div>
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilesView — Preact port of the Files view from client-main.js.
|
|
3
|
+
*
|
|
4
|
+
* On mount: fetches /api/files to build the grouped file tree.
|
|
5
|
+
* Clicking a file: fetches /api/file?path=... and renders markdown via the
|
|
6
|
+
* global `marked` CDN lib (stays a CDN global — unchanged from legacy).
|
|
7
|
+
*
|
|
8
|
+
* Agent-jump bridge: when the store field `requestedFile` is set (by
|
|
9
|
+
* AgentsView), FilesView picks it up and pre-fills the search filter, then
|
|
10
|
+
* clears the field so subsequent renders don't re-trigger.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { html, useState, useEffect, useCallback } from '../preact.js';
|
|
14
|
+
import { useStore, setState } from '../store.js';
|
|
15
|
+
import { showToast } from '../components/shared.js';
|
|
16
|
+
|
|
17
|
+
// ---- Markdown helpers (ported from client-main.js:287-294) ----
|
|
18
|
+
function stripFrontmatter(md) {
|
|
19
|
+
if (!md.startsWith('---')) return md;
|
|
20
|
+
const end = md.indexOf('\n---', 3);
|
|
21
|
+
return end === -1 ? md : md.slice(end + 4).trimStart();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderMd(md) {
|
|
25
|
+
const clean = stripFrontmatter(md);
|
|
26
|
+
return (typeof marked !== 'undefined')
|
|
27
|
+
? marked.parse(clean)
|
|
28
|
+
: '<pre>' + clean.replace(/</g, '<') + '</pre>';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---- File tree components ----
|
|
32
|
+
function FileEntry({ file, extraText, onSelect, isSelected }) {
|
|
33
|
+
return html`
|
|
34
|
+
<div
|
|
35
|
+
class=${'item item-clickable inline-file-entry' + (isSelected ? ' selected' : '')}
|
|
36
|
+
data-path=${file.path}
|
|
37
|
+
onClick=${() => onSelect(file)}
|
|
38
|
+
style="padding:var(--space-2) var(--space-3);font-family:'SF Mono',Monaco,Consolas,monospace;font-size:var(--text-xs);"
|
|
39
|
+
>
|
|
40
|
+
${file.label}
|
|
41
|
+
</div>
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function FileGroup({ group, onSelect, selectedPath, filter }) {
|
|
46
|
+
function matchesFilter(file, extra) {
|
|
47
|
+
if (!filter) return true;
|
|
48
|
+
const q = filter.toLowerCase();
|
|
49
|
+
return (file.label + ' ' + file.path + (extra ? ' ' + extra : '')).toLowerCase().includes(q);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (group.subGroups) {
|
|
53
|
+
return html`
|
|
54
|
+
<div class="inline-file-group" style="margin-bottom:var(--space-3);">
|
|
55
|
+
<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);">
|
|
56
|
+
${group.group}
|
|
57
|
+
</div>
|
|
58
|
+
${group.subGroups.map(sg => {
|
|
59
|
+
const visible = sg.files.filter(f => matchesFilter(f, sg.subGroup));
|
|
60
|
+
if (!visible.length) return null;
|
|
61
|
+
return html`
|
|
62
|
+
<details class="inline-subgroup" open style="margin-left:var(--space-2);margin-bottom:var(--space-1);">
|
|
63
|
+
<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;">
|
|
64
|
+
${sg.subGroup} <span style="color:var(--text-muted);font-weight:400;">(${visible.length})</span>
|
|
65
|
+
</summary>
|
|
66
|
+
${visible.map(f => html`
|
|
67
|
+
<${FileEntry}
|
|
68
|
+
key=${f.path}
|
|
69
|
+
file=${f}
|
|
70
|
+
extraText=${sg.subGroup}
|
|
71
|
+
onSelect=${onSelect}
|
|
72
|
+
isSelected=${selectedPath === f.path}
|
|
73
|
+
/>
|
|
74
|
+
`)}
|
|
75
|
+
</details>
|
|
76
|
+
`;
|
|
77
|
+
})}
|
|
78
|
+
</div>
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (group.files) {
|
|
83
|
+
const visible = group.files.filter(f => matchesFilter(f, ''));
|
|
84
|
+
if (!visible.length) return null;
|
|
85
|
+
return html`
|
|
86
|
+
<div class="inline-file-group" style="margin-bottom:var(--space-3);">
|
|
87
|
+
<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);">
|
|
88
|
+
${group.group}
|
|
89
|
+
</div>
|
|
90
|
+
${visible.map(f => html`
|
|
91
|
+
<${FileEntry}
|
|
92
|
+
key=${f.path}
|
|
93
|
+
file=${f}
|
|
94
|
+
onSelect=${onSelect}
|
|
95
|
+
isSelected=${selectedPath === f.path}
|
|
96
|
+
/>
|
|
97
|
+
`)}
|
|
98
|
+
</div>
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- File content pane ----
|
|
106
|
+
function FileContent({ path, html: htmlContent, loading, error }) {
|
|
107
|
+
if (loading) {
|
|
108
|
+
return html`
|
|
109
|
+
<div id="file-view">
|
|
110
|
+
<div class="skeleton"></div>
|
|
111
|
+
<div class="skeleton" style="height:200px;"></div>
|
|
112
|
+
</div>
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
if (error) {
|
|
116
|
+
return html`
|
|
117
|
+
<div id="file-view">
|
|
118
|
+
<div style="color:var(--accent-red);padding:16px;">${error}</div>
|
|
119
|
+
</div>
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
122
|
+
if (!path || !htmlContent) return html`<div id="file-view"></div>`;
|
|
123
|
+
|
|
124
|
+
function copyPath() {
|
|
125
|
+
navigator.clipboard.writeText(path).then(() => {
|
|
126
|
+
showToast('Path copied!');
|
|
127
|
+
}).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return html`
|
|
131
|
+
<div id="file-view">
|
|
132
|
+
<div class="file-path-header">
|
|
133
|
+
<span>${path}</span>
|
|
134
|
+
<button class="copy-btn" onClick=${copyPath}>Copy</button>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="md-render" dangerouslySetInnerHTML=${{ __html: htmlContent }} />
|
|
137
|
+
</div>
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- Root FilesView ----
|
|
142
|
+
export function FilesView() {
|
|
143
|
+
const { requestedFile } = useStore();
|
|
144
|
+
|
|
145
|
+
const [groups, setGroups] = useState([]);
|
|
146
|
+
const [loading, setLoading] = useState(true);
|
|
147
|
+
const [filter, setFilter] = useState('');
|
|
148
|
+
const [selectedPath, setSelectedPath] = useState(null);
|
|
149
|
+
const [fileContent, setFileContent] = useState({ html: null, loading: false, error: null });
|
|
150
|
+
|
|
151
|
+
// Fetch file tree on mount
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
setLoading(true);
|
|
154
|
+
fetch('/api/files')
|
|
155
|
+
.then(r => r.json())
|
|
156
|
+
.then(data => { setGroups(Array.isArray(data) ? data : []); })
|
|
157
|
+
.catch(() => setGroups([]))
|
|
158
|
+
.finally(() => setLoading(false));
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
// Agent-jump bridge: requestedFile set by AgentsView
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!requestedFile) return;
|
|
164
|
+
setFilter(requestedFile);
|
|
165
|
+
// Clear the bridge field so this doesn't re-trigger
|
|
166
|
+
setState({ requestedFile: null });
|
|
167
|
+
}, [requestedFile]);
|
|
168
|
+
|
|
169
|
+
const loadFile = useCallback(async (file) => {
|
|
170
|
+
setSelectedPath(file.path);
|
|
171
|
+
setFileContent({ html: null, loading: true, error: null });
|
|
172
|
+
try {
|
|
173
|
+
const resp = await fetch('/api/file?path=' + encodeURIComponent(file.path));
|
|
174
|
+
if (!resp.ok) {
|
|
175
|
+
setFileContent({ html: null, loading: false, error: 'Failed to load file.' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const text = await resp.text();
|
|
179
|
+
setFileContent({ html: renderMd(text), loading: false, error: null });
|
|
180
|
+
} catch {
|
|
181
|
+
setFileContent({ html: null, loading: false, error: 'Network error.' });
|
|
182
|
+
}
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
return html`
|
|
186
|
+
<div class="view active" id="view-files">
|
|
187
|
+
<div class="view-title">Files</div>
|
|
188
|
+
<div id="file-list-inline">
|
|
189
|
+
<div class="filter-bar">
|
|
190
|
+
<input
|
|
191
|
+
class="filter-input"
|
|
192
|
+
type="text"
|
|
193
|
+
placeholder="Search files…"
|
|
194
|
+
value=${filter}
|
|
195
|
+
onInput=${e => setFilter(e.target.value)}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
<div id="inline-file-items" class="phase-list">
|
|
199
|
+
${loading
|
|
200
|
+
? html`<div class="empty" style="margin:16px;">Loading…</div>`
|
|
201
|
+
: groups.length === 0
|
|
202
|
+
? html`<div class="empty" style="margin:16px;">No files found.</div>`
|
|
203
|
+
: groups.map(g => html`
|
|
204
|
+
<${FileGroup}
|
|
205
|
+
key=${g.group}
|
|
206
|
+
group=${g}
|
|
207
|
+
onSelect=${loadFile}
|
|
208
|
+
selectedPath=${selectedPath}
|
|
209
|
+
filter=${filter}
|
|
210
|
+
/>
|
|
211
|
+
`)
|
|
212
|
+
}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
<${FileContent}
|
|
216
|
+
path=${selectedPath}
|
|
217
|
+
html=${fileContent.html}
|
|
218
|
+
loading=${fileContent.loading}
|
|
219
|
+
error=${fileContent.error}
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KanbanView — Preact port of renderKanban() from client-kanban.js.
|
|
3
|
+
*
|
|
4
|
+
* Displays a 4-column board (todo / in_progress / blocked / done) with
|
|
5
|
+
* draggable cards. Drag-and-drop is visual-only (not persisted).
|
|
6
|
+
*
|
|
7
|
+
* Run/Stop/View card buttons call imported orchestrator functions (Sprint 31.4).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { html, useState, useCallback } from '../preact.js';
|
|
11
|
+
import { useStore } from '../store.js';
|
|
12
|
+
import { allTasks } from '../util.js';
|
|
13
|
+
import { runStory, stopStory, openOrchPanel } from '../orchestrator.js';
|
|
14
|
+
import { showToast } from '../components/shared.js';
|
|
15
|
+
|
|
16
|
+
// ---- Column descriptors ----
|
|
17
|
+
const COLS = [
|
|
18
|
+
{ id: 'todo', label: 'Todo', cssClass: 'col-todo' },
|
|
19
|
+
{ id: 'in_progress', label: 'In Progress', cssClass: 'col-prog' },
|
|
20
|
+
{ id: 'blocked', label: 'Blocked', cssClass: 'col-blocked' },
|
|
21
|
+
{ id: 'done', label: 'Done', cssClass: 'col-done' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Map a stored task status to a canonical column id. */
|
|
25
|
+
function kanbanCol(status) {
|
|
26
|
+
if (status === 'done' || status === 'completed') return 'done';
|
|
27
|
+
if (status === 'in_progress' || status === 'active' || status === 'running') return 'in_progress';
|
|
28
|
+
if (status === 'blocked') return 'blocked';
|
|
29
|
+
return 'todo';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Return the effective column, hoisting to in_progress if a live session exists. */
|
|
33
|
+
function effCol(task, activeSessions) {
|
|
34
|
+
const running = Array.isArray(activeSessions)
|
|
35
|
+
? activeSessions.some(s => s.storyId === task.id && s.status === 'running')
|
|
36
|
+
: false;
|
|
37
|
+
return (task.id && running) ? 'in_progress' : kanbanCol(task.status);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- Card component ----
|
|
41
|
+
function KanbanCard({ task, col, onDragStart, onDragEnd }) {
|
|
42
|
+
const sid = task.id || '';
|
|
43
|
+
const c = col;
|
|
44
|
+
const isRunning = c === 'in_progress';
|
|
45
|
+
const canRun = c === 'todo' || c === 'blocked';
|
|
46
|
+
const pts = task.points ? task.points + 'p' : null;
|
|
47
|
+
const phase = task.phaseId ? 'P' + task.phaseId : null;
|
|
48
|
+
const sprintMeta = [pts, phase].filter(Boolean).join(' · ');
|
|
49
|
+
|
|
50
|
+
function handleRun(e) {
|
|
51
|
+
e.stopPropagation();
|
|
52
|
+
runStory(sid);
|
|
53
|
+
}
|
|
54
|
+
function handleStop(e) {
|
|
55
|
+
e.stopPropagation();
|
|
56
|
+
stopStory(sid);
|
|
57
|
+
}
|
|
58
|
+
function handleView(e) {
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
openOrchPanel(sid);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return html`
|
|
64
|
+
<div
|
|
65
|
+
class=${'kanban-card s-' + c + (isRunning ? ' running' : '')}
|
|
66
|
+
data-story-id=${sid}
|
|
67
|
+
draggable=${!!sid}
|
|
68
|
+
onDragStart=${sid ? onDragStart : undefined}
|
|
69
|
+
onDragEnd=${sid ? onDragEnd : undefined}
|
|
70
|
+
>
|
|
71
|
+
<div class="kanban-card-header">
|
|
72
|
+
<div class="kanban-card-title">${task.title || task.id || 'Untitled'}</div>
|
|
73
|
+
${sid ? html`<div class="kanban-card-id">${sid.slice(0, 8)}</div>` : null}
|
|
74
|
+
</div>
|
|
75
|
+
${sprintMeta ? html`
|
|
76
|
+
<div class="kanban-card-meta">
|
|
77
|
+
<span class="kanban-card-sprint">${sprintMeta}</span>
|
|
78
|
+
<span class="kanban-card-status">${COLS.find(co => co.id === c)?.label || c}</span>
|
|
79
|
+
</div>
|
|
80
|
+
` : null}
|
|
81
|
+
${isRunning ? html`
|
|
82
|
+
<div class="card-run-indicator" id=${'run-ind-' + sid}>
|
|
83
|
+
<span class="run-pulse"></span>running
|
|
84
|
+
</div>
|
|
85
|
+
` : null}
|
|
86
|
+
${sid ? html`
|
|
87
|
+
<div class="kanban-card-actions">
|
|
88
|
+
${canRun ? html`
|
|
89
|
+
<button class="kanban-run-btn" onClick=${handleRun}>▶ Run</button>
|
|
90
|
+
` : isRunning ? html`
|
|
91
|
+
<button class="kanban-stop-btn" onClick=${handleStop}>■ Stop</button>
|
|
92
|
+
<button class="kanban-view-btn" onClick=${handleView}>↗ View</button>
|
|
93
|
+
` : html`
|
|
94
|
+
<button class="kanban-view-btn" onClick=${handleView}>↗ Logs</button>
|
|
95
|
+
`}
|
|
96
|
+
</div>
|
|
97
|
+
` : null}
|
|
98
|
+
</div>
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- Column component ----
|
|
103
|
+
function KanbanColumn({ col, cards, onDragStart, onDragEnd, onDragOver, onDrop }) {
|
|
104
|
+
return html`
|
|
105
|
+
<div class=${'kanban-col ' + col.cssClass} data-col=${col.id}>
|
|
106
|
+
<div class="kanban-col-head">
|
|
107
|
+
<span class="col-label">
|
|
108
|
+
<span class="col-status-dot"></span>
|
|
109
|
+
${col.label}
|
|
110
|
+
</span>
|
|
111
|
+
<span class="kanban-count">${cards.length}</span>
|
|
112
|
+
</div>
|
|
113
|
+
<div
|
|
114
|
+
class="kanban-col-body"
|
|
115
|
+
onDragOver=${e => { e.preventDefault(); onDragOver(e, col.id); }}
|
|
116
|
+
onDrop=${e => { e.preventDefault(); onDrop(e, col.id); }}
|
|
117
|
+
>
|
|
118
|
+
${cards.map(t => html`
|
|
119
|
+
<${KanbanCard}
|
|
120
|
+
key=${t.id || t.title}
|
|
121
|
+
task=${t}
|
|
122
|
+
col=${col.id}
|
|
123
|
+
onDragStart=${e => onDragStart(e, t)}
|
|
124
|
+
onDragEnd=${onDragEnd}
|
|
125
|
+
/>
|
|
126
|
+
`)}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- Root KanbanView ----
|
|
133
|
+
export function KanbanView() {
|
|
134
|
+
const { phases, activeSessions } = useStore();
|
|
135
|
+
const tasks = allTasks(phases);
|
|
136
|
+
|
|
137
|
+
// ---- Local column state (visual DnD overrides) ----
|
|
138
|
+
// Map<taskId, colId> — overrides the store-derived column for visual-only moves.
|
|
139
|
+
const [visualMoves, setVisualMoves] = useState({});
|
|
140
|
+
const [dragging, setDragging] = useState(null); // task being dragged
|
|
141
|
+
|
|
142
|
+
function getColFor(task) {
|
|
143
|
+
if (visualMoves[task.id]) return visualMoves[task.id];
|
|
144
|
+
return effCol(task, activeSessions);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Build buckets
|
|
148
|
+
const buckets = { todo: [], in_progress: [], blocked: [], done: [] };
|
|
149
|
+
for (const t of tasks) {
|
|
150
|
+
const c = getColFor(t);
|
|
151
|
+
if (buckets[c]) buckets[c].push(t);
|
|
152
|
+
else buckets.todo.push(t);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- DnD handlers ----
|
|
156
|
+
function handleDragStart(e, task) {
|
|
157
|
+
if (e.target.tagName === 'BUTTON') { e.preventDefault(); return; }
|
|
158
|
+
setDragging(task);
|
|
159
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
160
|
+
if (e.currentTarget) e.currentTarget.style.opacity = '0.5';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function handleDragEnd(e) {
|
|
164
|
+
if (e.currentTarget) e.currentTarget.style.opacity = '';
|
|
165
|
+
setDragging(null);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handleDragOver(e, colId) {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function handleDrop(e, colId) {
|
|
173
|
+
if (!dragging || !dragging.id) return;
|
|
174
|
+
setVisualMoves(prev => ({ ...prev, [dragging.id]: colId }));
|
|
175
|
+
setDragging(null);
|
|
176
|
+
showToast('Moved (visual only — not persisted)'); // visual only — not persisted
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- Manual refresh ----
|
|
180
|
+
function handleSync() {
|
|
181
|
+
if (typeof window._preactRefresh === 'function') window._preactRefresh();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function handleSessions() {
|
|
185
|
+
window.location.hash = 'orchestration';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!tasks.length) {
|
|
189
|
+
return html`
|
|
190
|
+
<div class="view active" id="view-kanban">
|
|
191
|
+
<div class="kanban-topbar">
|
|
192
|
+
<div class="kanban-topbar-title">
|
|
193
|
+
<span class="orch-status-dot" id="orch-dot"></span>
|
|
194
|
+
Kanban
|
|
195
|
+
</div>
|
|
196
|
+
<div class="kanban-topbar-actions">
|
|
197
|
+
<button class="kanban-refresh-btn" onClick=${handleSync}>⟳ Sync</button>
|
|
198
|
+
<button class="kanban-refresh-btn" style="margin-left:4px;" onClick=${handleSessions}>⊞ Sessions</button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="empty" style="margin:24px;">
|
|
202
|
+
No stories yet.
|
|
203
|
+
<div class="empty-action">/rihal-plan to generate tasks</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return html`
|
|
210
|
+
<div class="view active" id="view-kanban">
|
|
211
|
+
<div class="kanban-topbar">
|
|
212
|
+
<div class="kanban-topbar-title">
|
|
213
|
+
<span class="orch-status-dot" id="orch-dot"></span>
|
|
214
|
+
Kanban
|
|
215
|
+
</div>
|
|
216
|
+
<div class="kanban-topbar-actions">
|
|
217
|
+
<button class="kanban-refresh-btn" onClick=${handleSync}>⟳ Sync</button>
|
|
218
|
+
<button class="kanban-refresh-btn" style="margin-left:4px;" onClick=${handleSessions}>⊞ Sessions</button>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<div class="kanban-board">
|
|
222
|
+
${COLS.map(col => html`
|
|
223
|
+
<${KanbanColumn}
|
|
224
|
+
key=${col.id}
|
|
225
|
+
col=${col}
|
|
226
|
+
cards=${buckets[col.id] || []}
|
|
227
|
+
onDragStart=${handleDragStart}
|
|
228
|
+
onDragEnd=${handleDragEnd}
|
|
229
|
+
onDragOver=${handleDragOver}
|
|
230
|
+
onDrop=${handleDrop}
|
|
231
|
+
/>
|
|
232
|
+
`)}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
`;
|
|
236
|
+
}
|