@hanzlaa/rcode 3.5.0 → 3.6.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/package.json +7 -1
- package/rihal/bin/rihal-tools.cjs +274 -31
- 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 +280 -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 +265 -56
- package/server/lib/html/icons.js +68 -0
- package/server/lib/html/shell.js +9 -296
- package/server/lib/scanner.js +89 -0
- package/server/orchestrator.js +252 -310
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App — root Preact component.
|
|
3
|
+
*
|
|
4
|
+
* Owns:
|
|
5
|
+
* - Hash router (view + subId state, hashchange listener)
|
|
6
|
+
* - Layout: Sidebar + content area + Topbar + all 12 Preact view components
|
|
7
|
+
* - 30s auto-refresh: polls /api/state, diffs lastScanned, calls setState
|
|
8
|
+
* - Theme toggle: reads/persists localStorage('majlis-theme')
|
|
9
|
+
*
|
|
10
|
+
* Sprint 31.4 completed the Preact migration. All 12 views are Preact
|
|
11
|
+
* components. Legacy client-main.js, client-render.js, and client-kanban.js
|
|
12
|
+
* are deleted. No coexistence seam remains.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
|
|
16
|
+
import { getState, setState, subscribe } from '../store.js';
|
|
17
|
+
import { startSessionsPoll, refreshOrchToken } from '../orchestrator.js';
|
|
18
|
+
import { Sidebar } from './Sidebar.js';
|
|
19
|
+
import { Topbar } from './Topbar.js';
|
|
20
|
+
import { XtermPanel } from './XtermPanel.js';
|
|
21
|
+
import { OrchPanel } from './OrchPanel.js';
|
|
22
|
+
import { OverviewView } from '../views/OverviewView.js';
|
|
23
|
+
import { DecisionsView } from '../views/DecisionsView.js';
|
|
24
|
+
import { RoadmapView } from '../views/RoadmapView.js';
|
|
25
|
+
import { MilestonesView } from '../views/MilestonesView.js';
|
|
26
|
+
import { PhasesView } from '../views/PhasesView.js';
|
|
27
|
+
import { SprintsView } from '../views/SprintsView.js';
|
|
28
|
+
import { TasksView } from '../views/TasksView.js';
|
|
29
|
+
import { KanbanView } from '../views/KanbanView.js';
|
|
30
|
+
import { FilesView } from '../views/FilesView.js';
|
|
31
|
+
import { AgentsView } from '../views/AgentsView.js';
|
|
32
|
+
import { MemoryView } from '../views/MemoryView.js';
|
|
33
|
+
import { OrchestrationView } from '../views/OrchestrationView.js';
|
|
34
|
+
|
|
35
|
+
// Views served by Preact components (migrated)
|
|
36
|
+
// Sprint 31.4: +orchestration → all 12 views Preact. Migration complete.
|
|
37
|
+
const PREACT_VIEWS = {
|
|
38
|
+
overview: OverviewView,
|
|
39
|
+
decisions: DecisionsView,
|
|
40
|
+
roadmap: RoadmapView,
|
|
41
|
+
milestones: MilestonesView,
|
|
42
|
+
phases: PhasesView,
|
|
43
|
+
sprints: SprintsView,
|
|
44
|
+
tasks: TasksView,
|
|
45
|
+
kanban: KanbanView,
|
|
46
|
+
files: FilesView,
|
|
47
|
+
agents: AgentsView,
|
|
48
|
+
memory: MemoryView,
|
|
49
|
+
orchestration: OrchestrationView,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// All views are now Preact — no legacy placeholder hosts needed.
|
|
53
|
+
const LEGACY_VIEWS = [];
|
|
54
|
+
|
|
55
|
+
const ALL_VIEWS = Object.keys(PREACT_VIEWS).concat(LEGACY_VIEWS);
|
|
56
|
+
|
|
57
|
+
/** Parse location.hash into { view, subId } — port of client-main.js:45-49. */
|
|
58
|
+
function parseHash() {
|
|
59
|
+
const raw = location.hash.slice(1) || 'overview';
|
|
60
|
+
const slash = raw.indexOf('/');
|
|
61
|
+
const view = slash === -1 ? raw : raw.slice(0, slash);
|
|
62
|
+
const subId = slash === -1 ? null : raw.slice(slash + 1);
|
|
63
|
+
// #263: unknown hash falls back to overview
|
|
64
|
+
const resolvedView = ALL_VIEWS.includes(view) ? view : 'overview';
|
|
65
|
+
return { view: resolvedView, subId };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Root App component. No props needed — reads everything from the store. */
|
|
69
|
+
export function App() {
|
|
70
|
+
// ---- Router state ----
|
|
71
|
+
const [{ view, subId }, setRoute] = useState(parseHash);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
function onHashChange() { setRoute(parseHash()); }
|
|
75
|
+
window.addEventListener('hashchange', onHashChange);
|
|
76
|
+
return () => window.removeEventListener('hashchange', onHashChange);
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
// ---- Store state (for projectName and pass-through to views) ----
|
|
80
|
+
const [storeState, setStoreState] = useState(getState);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const unsub = subscribe(newState => setStoreState({ ...newState }));
|
|
83
|
+
return unsub;
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
// ---- Theme ----
|
|
87
|
+
const [theme, setTheme] = useState(() => {
|
|
88
|
+
const saved = localStorage.getItem('majlis-theme') || 'dark';
|
|
89
|
+
if (saved === 'light') {
|
|
90
|
+
document.documentElement.setAttribute('data-theme', 'light');
|
|
91
|
+
}
|
|
92
|
+
return saved;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const toggleTheme = useCallback(() => {
|
|
96
|
+
const next = theme === 'light' ? 'dark' : 'light';
|
|
97
|
+
document.documentElement.setAttribute('data-theme', next === 'dark' ? '' : next);
|
|
98
|
+
localStorage.setItem('majlis-theme', next);
|
|
99
|
+
setTheme(next);
|
|
100
|
+
}, [theme]);
|
|
101
|
+
|
|
102
|
+
// ---- Sidebar collapse ----
|
|
103
|
+
const toggleSidebar = useCallback(() => {
|
|
104
|
+
const sidebar = document.querySelector('.sidebar');
|
|
105
|
+
const backdrop = document.getElementById('sidebar-backdrop');
|
|
106
|
+
if (!sidebar) return;
|
|
107
|
+
const open = sidebar.classList.toggle('sidebar-open');
|
|
108
|
+
if (backdrop) backdrop.classList.toggle('active', open);
|
|
109
|
+
document.body.classList.toggle('sidebar-visible', open);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// ---- Updated-ago display ----
|
|
113
|
+
const [updatedAgo, setUpdatedAgo] = useState('just now');
|
|
114
|
+
const scanTimeRef = useRef(Date.now());
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const id = setInterval(() => {
|
|
118
|
+
const s = Math.floor((Date.now() - scanTimeRef.current) / 1000);
|
|
119
|
+
setUpdatedAgo(s < 5 ? 'just now' : s < 60 ? s + 's ago' : Math.floor(s / 60) + 'm ago');
|
|
120
|
+
}, 1000);
|
|
121
|
+
return () => clearInterval(id);
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
// ---- Manual refresh ----
|
|
125
|
+
const lastScannedRef = useRef(null);
|
|
126
|
+
|
|
127
|
+
const fetchAndRerender = useCallback(async () => {
|
|
128
|
+
const btn = document.getElementById('refresh-btn');
|
|
129
|
+
if (btn) btn.textContent = '↺ …';
|
|
130
|
+
try {
|
|
131
|
+
const r = await fetch('/api/state');
|
|
132
|
+
if (!r.ok) return;
|
|
133
|
+
const newState = await r.json();
|
|
134
|
+
lastScannedRef.current = newState.lastScanned;
|
|
135
|
+
scanTimeRef.current = Date.now();
|
|
136
|
+
setUpdatedAgo('just now');
|
|
137
|
+
if (newState.raw) {
|
|
138
|
+
setState({
|
|
139
|
+
phases: newState.phaseTree || newState.raw.phases || [],
|
|
140
|
+
milestone: newState.raw.milestone || '',
|
|
141
|
+
currentPhase: newState.raw.current_phase || null,
|
|
142
|
+
currentSprint: newState.raw.current_sprint || null,
|
|
143
|
+
decisions: newState.raw.decisions || [],
|
|
144
|
+
blockers: newState.raw.blockers || [],
|
|
145
|
+
council_sessions: newState.raw.council_sessions || [],
|
|
146
|
+
last_session: newState.raw.last_session || null,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
} catch { /* network errors ignored */ }
|
|
150
|
+
if (btn) btn.textContent = '↺ Refresh';
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
// ---- 30s auto-refresh ----
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const id = setInterval(async () => {
|
|
156
|
+
try {
|
|
157
|
+
const r = await fetch('/api/state');
|
|
158
|
+
if (!r.ok) return;
|
|
159
|
+
const s = await r.json();
|
|
160
|
+
if (s.lastScanned !== lastScannedRef.current) await fetchAndRerender();
|
|
161
|
+
} catch { /* ignore */ }
|
|
162
|
+
}, 30000);
|
|
163
|
+
return () => clearInterval(id);
|
|
164
|
+
}, [fetchAndRerender]);
|
|
165
|
+
|
|
166
|
+
// Expose manualRefresh globally for any legacy onclick="manualRefresh()" callers
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
window._preactRefresh = fetchAndRerender;
|
|
169
|
+
}, [fetchAndRerender]);
|
|
170
|
+
|
|
171
|
+
// Start the global session poll and refresh the orchestrator token on boot.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
refreshOrchToken();
|
|
174
|
+
startSessionsPoll();
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
// ---- View rendering ----
|
|
178
|
+
const PreactView = PREACT_VIEWS[view] || null;
|
|
179
|
+
|
|
180
|
+
return html`
|
|
181
|
+
<div class="app-shell">
|
|
182
|
+
<${Sidebar} activeView=${view} projectName=${storeState.projectName || ''} />
|
|
183
|
+
|
|
184
|
+
<div id="sidebar-backdrop" onClick=${() => {
|
|
185
|
+
const sidebar = document.querySelector('.sidebar');
|
|
186
|
+
const backdrop = document.getElementById('sidebar-backdrop');
|
|
187
|
+
if (sidebar) sidebar.classList.remove('sidebar-open');
|
|
188
|
+
if (backdrop) backdrop.classList.remove('active');
|
|
189
|
+
document.body.classList.remove('sidebar-visible');
|
|
190
|
+
}}></div>
|
|
191
|
+
|
|
192
|
+
<div class="content-area" id="main-content">
|
|
193
|
+
<${Topbar}
|
|
194
|
+
projectName=${storeState.projectName || ''}
|
|
195
|
+
updatedAgo=${updatedAgo}
|
|
196
|
+
onRefresh=${fetchAndRerender}
|
|
197
|
+
onToggleTheme=${toggleTheme}
|
|
198
|
+
onToggleSidebar=${toggleSidebar}
|
|
199
|
+
themeLabel=${theme}
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
<div class="main-scroll" id="main-scroll">
|
|
203
|
+
${PreactView ? html`<${PreactView} subId=${subId} />` : null}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<${XtermPanel} />
|
|
208
|
+
<${OrchPanel} />
|
|
209
|
+
</div>
|
|
210
|
+
`;
|
|
211
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrchPanel — Preact port of the #orch-panel orchestrator side panel.
|
|
3
|
+
*
|
|
4
|
+
* Displays a tab strip of SSE-streamed agent sessions with live output,
|
|
5
|
+
* file-change tracking, and footer controls (Stop / Clear / Clean).
|
|
6
|
+
*
|
|
7
|
+
* Driven by store.orchPanel = { open, storyId }.
|
|
8
|
+
* Session data is held in component state (sessionsMap) — each session has:
|
|
9
|
+
* { title, lines: [], fileOps: [], status }
|
|
10
|
+
*
|
|
11
|
+
* The SSE stream (connectOrchestratorStream) appends chunks/lines/fileOps
|
|
12
|
+
* as component state updates, which causes Preact to re-render the terminal
|
|
13
|
+
* body. No direct DOM manipulation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
|
|
17
|
+
import { useStore, setState } from '../store.js';
|
|
18
|
+
import { orchToken, stopSession, cleanSessions, ORCH_HTTP } from '../orchestrator.js';
|
|
19
|
+
import { showToast } from './shared.js';
|
|
20
|
+
import { Icon } from '../icons-client.js';
|
|
21
|
+
|
|
22
|
+
// ── Session map helpers ───────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function mkSession(title) {
|
|
25
|
+
return { title: title || 'Session', lines: [], fileOps: [], status: 'starting' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── SSE streams (module-scoped — one EventSource per storyId) ────────────────
|
|
29
|
+
const _streams = {};
|
|
30
|
+
|
|
31
|
+
function closeStream(storyId) {
|
|
32
|
+
if (_streams[storyId]) { _streams[storyId].close(); delete _streams[storyId]; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export function OrchPanel() {
|
|
38
|
+
const { orchPanel } = useStore();
|
|
39
|
+
const open = !!(orchPanel && orchPanel.open);
|
|
40
|
+
const reqStory = orchPanel && orchPanel.storyId;
|
|
41
|
+
|
|
42
|
+
// sessionsMap: { [storyId]: { title, lines, fileOps, status } }
|
|
43
|
+
const [sessionsMap, setSessionsMap] = useState({});
|
|
44
|
+
const [activeTab, setActiveTab ] = useState(null);
|
|
45
|
+
const bodyRef = useRef(null);
|
|
46
|
+
|
|
47
|
+
// Scroll to bottom whenever lines change for the active tab
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
|
50
|
+
}, [sessionsMap, activeTab]);
|
|
51
|
+
|
|
52
|
+
// Close all SSE streams on unmount to prevent leaking EventSource connections.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
return () => {
|
|
55
|
+
Object.keys(_streams).forEach(closeStream);
|
|
56
|
+
};
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// When orchPanel is opened with a storyId, create the tab and connect SSE
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!reqStory) return;
|
|
62
|
+
setSessionsMap(prev => {
|
|
63
|
+
if (prev[reqStory]) return prev;
|
|
64
|
+
return { ...prev, [reqStory]: mkSession(reqStory) };
|
|
65
|
+
});
|
|
66
|
+
setActiveTab(reqStory);
|
|
67
|
+
// Connect SSE if not already connected
|
|
68
|
+
if (!_streams[reqStory]) {
|
|
69
|
+
connectStream(reqStory);
|
|
70
|
+
}
|
|
71
|
+
}, [reqStory]);
|
|
72
|
+
|
|
73
|
+
function connectStream(storyId) {
|
|
74
|
+
const tok = orchToken();
|
|
75
|
+
const es = new EventSource(
|
|
76
|
+
ORCH_HTTP + '/api/stream/' + encodeURIComponent(storyId) +
|
|
77
|
+
'?token=' + encodeURIComponent(tok || '')
|
|
78
|
+
);
|
|
79
|
+
_streams[storyId] = es;
|
|
80
|
+
|
|
81
|
+
function appendLine(storyId, line, cls) {
|
|
82
|
+
setSessionsMap(prev => {
|
|
83
|
+
const sess = prev[storyId];
|
|
84
|
+
if (!sess) return prev;
|
|
85
|
+
return {
|
|
86
|
+
...prev,
|
|
87
|
+
[storyId]: { ...sess, lines: [...sess.lines, { text: line, cls }] },
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function appendChunk(storyId, chunk) {
|
|
93
|
+
setSessionsMap(prev => {
|
|
94
|
+
const sess = prev[storyId];
|
|
95
|
+
if (!sess) return prev;
|
|
96
|
+
const lines = sess.lines;
|
|
97
|
+
const last = lines[lines.length - 1];
|
|
98
|
+
if (last && last.cls === 'kt-stream') {
|
|
99
|
+
const updated = [...lines];
|
|
100
|
+
updated[updated.length - 1] = { ...last, text: last.text + chunk };
|
|
101
|
+
return { ...prev, [storyId]: { ...sess, lines: updated } };
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
...prev,
|
|
105
|
+
[storyId]: { ...sess, lines: [...lines, { text: chunk, cls: 'kt-stream' }] },
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function appendFileOp(storyId, fileOp) {
|
|
111
|
+
setSessionsMap(prev => {
|
|
112
|
+
const sess = prev[storyId];
|
|
113
|
+
if (!sess) return prev;
|
|
114
|
+
return {
|
|
115
|
+
...prev,
|
|
116
|
+
[storyId]: { ...sess, fileOps: [...sess.fileOps, fileOp] },
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function setTabStatus(storyId, status) {
|
|
122
|
+
setSessionsMap(prev => {
|
|
123
|
+
const sess = prev[storyId];
|
|
124
|
+
if (!sess) return prev;
|
|
125
|
+
return { ...prev, [storyId]: { ...sess, status } };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
es.onmessage = e => {
|
|
130
|
+
try {
|
|
131
|
+
const d = JSON.parse(e.data);
|
|
132
|
+
if (d.chunk) appendChunk(storyId, d.chunk);
|
|
133
|
+
if (d.line) {
|
|
134
|
+
let cls = 'kt-line';
|
|
135
|
+
const l = d.line;
|
|
136
|
+
if (l.startsWith('⚙')) cls += ' tool';
|
|
137
|
+
else if (l.startsWith('⚠')) cls += ' warn';
|
|
138
|
+
else if (l.startsWith('✗')) cls += ' err';
|
|
139
|
+
else if (l.startsWith('✅')) cls += ' done-line';
|
|
140
|
+
else if (l.startsWith('▶') || l.startsWith('◉') || l.startsWith('■')) cls += ' meta';
|
|
141
|
+
appendLine(storyId, l, cls);
|
|
142
|
+
}
|
|
143
|
+
if (d.fileOp) appendFileOp(storyId, d.fileOp);
|
|
144
|
+
if (d.status) {
|
|
145
|
+
setTabStatus(storyId, d.status);
|
|
146
|
+
if (d.status === 'done') appendLine(storyId, '✅ Done', 'kt-line done-line');
|
|
147
|
+
if (d.status === 'stopped') appendLine(storyId, '■ Stopped', 'kt-line meta');
|
|
148
|
+
if (d.status !== 'running') { closeStream(storyId); }
|
|
149
|
+
}
|
|
150
|
+
} catch { /* ignore parse errors */ }
|
|
151
|
+
};
|
|
152
|
+
es.onerror = () => {
|
|
153
|
+
setTabStatus(storyId, 'error');
|
|
154
|
+
closeStream(storyId);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const handleClose = useCallback(() => {
|
|
159
|
+
setState({ orchPanel: null });
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
function handleTabClick(storyId) {
|
|
163
|
+
setActiveTab(storyId);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function handleTabClose(e, storyId) {
|
|
167
|
+
e.stopPropagation();
|
|
168
|
+
closeStream(storyId);
|
|
169
|
+
setSessionsMap(prev => {
|
|
170
|
+
const next = { ...prev };
|
|
171
|
+
delete next[storyId];
|
|
172
|
+
return next;
|
|
173
|
+
});
|
|
174
|
+
if (activeTab === storyId) {
|
|
175
|
+
const remaining = Object.keys(sessionsMap).filter(k => k !== storyId);
|
|
176
|
+
setActiveTab(remaining[0] || null);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function handleStop() {
|
|
181
|
+
if (activeTab) stopSession(activeTab);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function handleClear() {
|
|
185
|
+
if (!activeTab) return;
|
|
186
|
+
setSessionsMap(prev => {
|
|
187
|
+
const sess = prev[activeTab];
|
|
188
|
+
if (!sess) return prev;
|
|
189
|
+
return { ...prev, [activeTab]: { ...sess, lines: [], fileOps: [] } };
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function handleClean() {
|
|
194
|
+
cleanSessions().then(d => {
|
|
195
|
+
showToast('Cleaned ' + (d.removed || 0) + ' sessions');
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const tabs = Object.keys(sessionsMap);
|
|
200
|
+
const activeSess = activeTab ? sessionsMap[activeTab] : null;
|
|
201
|
+
const hasStream = activeTab && !!_streams[activeTab];
|
|
202
|
+
const runningCount = Object.keys(_streams).length;
|
|
203
|
+
|
|
204
|
+
const panelCls = 'orch-panel' + (open ? ' open' : '');
|
|
205
|
+
|
|
206
|
+
return html`
|
|
207
|
+
<div class=${panelCls}>
|
|
208
|
+
<div class="orch-panel-header">
|
|
209
|
+
<div class="orch-panel-title">
|
|
210
|
+
<span class=${'orch-status-dot' + (runningCount > 0 ? ' up' : '')}></span>
|
|
211
|
+
Agent Sessions
|
|
212
|
+
</div>
|
|
213
|
+
<button class="orch-panel-close" onClick=${handleClose} title="Close" aria-label="Close panel"><${Icon} name="x" size=${14}/></button>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<!-- Tab strip -->
|
|
217
|
+
<div class="orch-tabs">
|
|
218
|
+
${tabs.length === 0 ? html`
|
|
219
|
+
<div class="orch-term-empty orch-empty-tab">
|
|
220
|
+
No active sessions
|
|
221
|
+
</div>
|
|
222
|
+
` : tabs.map(sid => {
|
|
223
|
+
const sess = sessionsMap[sid];
|
|
224
|
+
const isActive = sid === activeTab;
|
|
225
|
+
return html`
|
|
226
|
+
<button
|
|
227
|
+
key=${sid}
|
|
228
|
+
class=${'orch-tab' + (isActive ? ' active' : '')}
|
|
229
|
+
onClick=${() => handleTabClick(sid)}
|
|
230
|
+
>
|
|
231
|
+
<span class=${'tab-status-dot ' + (sess.status || 'starting')}></span>
|
|
232
|
+
<span>${(sess.title || sid).slice(0, 20)}</span>
|
|
233
|
+
<button
|
|
234
|
+
class="orch-tab-close"
|
|
235
|
+
onClick=${e => handleTabClose(e, sid)}
|
|
236
|
+
title="Close"
|
|
237
|
+
aria-label="Close tab"
|
|
238
|
+
><${Icon} name="x" size=${12}/></button>
|
|
239
|
+
</button>
|
|
240
|
+
`;
|
|
241
|
+
})}
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<!-- Terminal body -->
|
|
245
|
+
<div class="orch-terminal">
|
|
246
|
+
<div class="orch-term-body" ref=${bodyRef}>
|
|
247
|
+
${!activeSess ? html`
|
|
248
|
+
<div class="orch-term-empty">
|
|
249
|
+
<div>Select a session or run a story card</div>
|
|
250
|
+
</div>
|
|
251
|
+
` : activeSess.lines.length === 0 ? html`
|
|
252
|
+
<div class="orch-term-empty">
|
|
253
|
+
<div>No output yet for ${activeTab}</div>
|
|
254
|
+
</div>
|
|
255
|
+
` : activeSess.lines.map((line, i) => html`
|
|
256
|
+
<div key=${i} class=${line.cls}>${line.text}</div>
|
|
257
|
+
`)}
|
|
258
|
+
</div>
|
|
259
|
+
${activeSess && activeSess.fileOps.length > 0 ? html`
|
|
260
|
+
<div class="orch-files">
|
|
261
|
+
<div class="orch-files-head">File changes</div>
|
|
262
|
+
${activeSess.fileOps.map((fo, i) => {
|
|
263
|
+
const opClass = fo.op === 'write' ? 'op-w' : fo.op === 'bash' ? 'op-b' : 'op-r';
|
|
264
|
+
const opLabel = fo.op === 'write' ? '✎' : fo.op === 'bash' ? '$' : null;
|
|
265
|
+
const label = fo.path || fo.cmd || fo.tool || '';
|
|
266
|
+
return html`
|
|
267
|
+
<div key=${i} class="kt-file">
|
|
268
|
+
<span class=${'op-icon ' + opClass}>${fo.op !== 'write' && fo.op !== 'bash' ? html`<${Icon} name="eye" size=${12}/>` : opLabel}</span> ${label}
|
|
269
|
+
</div>
|
|
270
|
+
`;
|
|
271
|
+
})}
|
|
272
|
+
</div>
|
|
273
|
+
` : null}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<!-- Footer -->
|
|
277
|
+
<div class="orch-panel-footer">
|
|
278
|
+
<!-- style= here is intentional: dynamic display:none toggle — replacing with a CSS class would require extra state wiring -->
|
|
279
|
+
<button
|
|
280
|
+
class="orch-footer-btn stop"
|
|
281
|
+
style=${hasStream ? '' : 'display:none'}
|
|
282
|
+
onClick=${handleStop}
|
|
283
|
+
>■ Stop</button>
|
|
284
|
+
<button class="orch-footer-btn" onClick=${handleClear}>Clear</button>
|
|
285
|
+
<button class="orch-footer-btn" onClick=${handleClean}>Clean sessions…</button>
|
|
286
|
+
<div class="orch-footer-spacer"></div>
|
|
287
|
+
<span class="orch-footer-status">
|
|
288
|
+
${runningCount > 0 ? runningCount + ' running' : ''}
|
|
289
|
+
</span>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
`;
|
|
293
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar component — project label, nav sections, 12 nav-link buttons.
|
|
3
|
+
*
|
|
4
|
+
* Reuses existing CSS classes from css.js: sidebar, nav-section, nav-link,
|
|
5
|
+
* data-view, active. Emoji replaced with SVG icons from icons-client.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html } from '../preact.js';
|
|
9
|
+
import { Icon } from '../icons-client.js';
|
|
10
|
+
|
|
11
|
+
// Nav structure: [ { section, links: [ { view, icon, label } ] } ]
|
|
12
|
+
const NAV_SECTIONS = [
|
|
13
|
+
{
|
|
14
|
+
section: 'Overview',
|
|
15
|
+
links: [
|
|
16
|
+
{ view: 'overview', icon: 'home', label: 'Overview' },
|
|
17
|
+
{ view: 'orchestration', icon: 'activity', label: 'Orchestration' },
|
|
18
|
+
{ view: 'roadmap', icon: 'map', label: 'Roadmap' },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
section: 'Planning',
|
|
23
|
+
links: [
|
|
24
|
+
{ view: 'milestones', icon: 'target', label: 'Milestones' },
|
|
25
|
+
{ view: 'phases', icon: 'layers', label: 'Phases' },
|
|
26
|
+
{ view: 'sprints', icon: 'zap', label: 'Sprints' },
|
|
27
|
+
{ view: 'tasks', icon: 'checkSquare', label: 'Tasks' },
|
|
28
|
+
{ view: 'kanban', icon: 'kanban', label: 'Kanban' },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
section: 'Workspace',
|
|
33
|
+
links: [
|
|
34
|
+
{ view: 'files', icon: 'file', label: 'Files' },
|
|
35
|
+
{ view: 'agents', icon: 'users', label: 'Agents' },
|
|
36
|
+
{ view: 'decisions', icon: 'scale', label: 'Decisions' },
|
|
37
|
+
{ view: 'memory', icon: 'database', label: 'Memory' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sidebar component.
|
|
44
|
+
*
|
|
45
|
+
* Props:
|
|
46
|
+
* activeView {string} — currently active view key
|
|
47
|
+
* projectName {string} — displayed under the "Rihal" label
|
|
48
|
+
*/
|
|
49
|
+
export function Sidebar({ activeView, projectName }) {
|
|
50
|
+
return html`
|
|
51
|
+
<aside class="sidebar" id="sidebar">
|
|
52
|
+
<div class="sidebar-project">
|
|
53
|
+
<div class="project-label">Rihal</div>
|
|
54
|
+
<span>${projectName || ''}</span>
|
|
55
|
+
</div>
|
|
56
|
+
<nav>
|
|
57
|
+
${NAV_SECTIONS.map(({ section, links }) => html`
|
|
58
|
+
<div class="nav-section">${section}</div>
|
|
59
|
+
${links.map(({ view, icon, label }) => html`
|
|
60
|
+
<button
|
|
61
|
+
class=${'nav-link' + (activeView === view ? ' active' : '')}
|
|
62
|
+
data-view=${view}
|
|
63
|
+
onClick=${() => { location.hash = view; }}
|
|
64
|
+
>
|
|
65
|
+
<${Icon} name=${icon} size=${14} />
|
|
66
|
+
${' ' + label}
|
|
67
|
+
</button>
|
|
68
|
+
`)}
|
|
69
|
+
`)}
|
|
70
|
+
</nav>
|
|
71
|
+
</aside>
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topbar component — brand, live dot, updated-ago, action buttons.
|
|
3
|
+
*
|
|
4
|
+
* Reuses existing CSS classes: header-actions, header-btn, live, hamburger-btn.
|
|
5
|
+
*
|
|
6
|
+
* Props:
|
|
7
|
+
* projectName {string} — shown in the brand subtitle
|
|
8
|
+
* updatedAgo {string} — text for the "updated N ago" span
|
|
9
|
+
* onRefresh {function} — called when Refresh button is clicked
|
|
10
|
+
* onToggleTheme {function} — called when theme button is clicked
|
|
11
|
+
* onToggleSidebar {function} — called when hamburger is clicked
|
|
12
|
+
* themeLabel {string} — 'light' or 'dark' — controls which icon the theme button shows
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { html } from '../preact.js';
|
|
16
|
+
import { Icon } from '../icons-client.js';
|
|
17
|
+
|
|
18
|
+
export function Topbar({ projectName, updatedAgo, onRefresh, onToggleTheme, onToggleSidebar, themeLabel }) {
|
|
19
|
+
return html`
|
|
20
|
+
<header>
|
|
21
|
+
<div class="topbar-start-group">
|
|
22
|
+
<button
|
|
23
|
+
class="hamburger-btn"
|
|
24
|
+
id="hamburger-btn"
|
|
25
|
+
onClick=${onToggleSidebar}
|
|
26
|
+
aria-label="Toggle menu"
|
|
27
|
+
>
|
|
28
|
+
<span></span><span></span><span></span>
|
|
29
|
+
</button>
|
|
30
|
+
<div class="brand">
|
|
31
|
+
<div class="icon"><${Icon} name="building" size=${16} cls="brand-icon"/></div>
|
|
32
|
+
<div>
|
|
33
|
+
<h1>Majlis — The Council</h1>
|
|
34
|
+
<div class="arabic">مجلس · ${projectName || ''}</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="header-actions">
|
|
39
|
+
<span class="live" id="live-dot" title="Live"></span>
|
|
40
|
+
<span id="updated-ago" class="updated-ago">${updatedAgo || 'just now'}</span>
|
|
41
|
+
<button class="header-btn" id="refresh-btn" onClick=${onRefresh}>↺ Refresh</button>
|
|
42
|
+
<!-- icon shows TARGET state (not current): dark→sun means "click to go light"; light→moon means "click to go dark" -->
|
|
43
|
+
<button class="header-btn" id="theme-btn" onClick=${onToggleTheme} title="Toggle theme"><${Icon} name=${themeLabel === 'light' ? 'moon' : 'sun'} size=${14}/></button>
|
|
44
|
+
<button class="header-btn" onClick=${() => {
|
|
45
|
+
navigator.clipboard.writeText(location.href);
|
|
46
|
+
// Show a toast if available
|
|
47
|
+
const toast = document.getElementById('toast');
|
|
48
|
+
if (toast) { toast.textContent = 'URL copied!'; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 2500); }
|
|
49
|
+
}} title="Copy URL">⎘ Link</button>
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
`;
|
|
53
|
+
}
|