@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,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(7).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
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XtermPanel — Preact wrapper around the CDN xterm.js terminal.
|
|
3
|
+
*
|
|
4
|
+
* xterm.js is NOT replaced — it stays the CDN global (Terminal, FitAddon)
|
|
5
|
+
* loaded by shell.js. This component manages:
|
|
6
|
+
* - One shared xterm instance (built once in a useRef, reused per session)
|
|
7
|
+
* - WebSocket lifecycle: connect on open, write output, send keystrokes/resize
|
|
8
|
+
* - Panel visibility: open / minimized-pill / fullscreen controlled by store
|
|
9
|
+
*
|
|
10
|
+
* Store field: state.terminal = { open, storyId, title, minimized, fullscreen }
|
|
11
|
+
* Setting state.terminal via orchestrator.js triggers this component.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { html, useEffect, useRef, useCallback } from '../preact.js';
|
|
15
|
+
import { useStore, setState } from '../store.js';
|
|
16
|
+
import { orchToken, stopSession, ORCH_WS } from '../orchestrator.js';
|
|
17
|
+
|
|
18
|
+
// ── Internal state (module-scoped, one panel at a time) ──────────────────────
|
|
19
|
+
// These refs are NOT component state because the xterm instance must persist
|
|
20
|
+
// across panel open/close cycles and Preact re-renders.
|
|
21
|
+
let _term = null;
|
|
22
|
+
let _termFit = null;
|
|
23
|
+
let _termWs = null;
|
|
24
|
+
|
|
25
|
+
function setStatus(dotStatus) {
|
|
26
|
+
// Propagate connection status via a store signal so the pill/header can react
|
|
27
|
+
setState({ termStatus: dotStatus || '' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _resize() {
|
|
31
|
+
if (_termFit) { try { _termFit.fit(); } catch (_e) {} }
|
|
32
|
+
if (_term && _termWs && _termWs.readyState === 1) {
|
|
33
|
+
_termWs.send(JSON.stringify({ t: 'r', cols: _term.cols, rows: _term.rows }));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Build the xterm instance exactly once; attach to `containerEl`. */
|
|
38
|
+
function ensureTerm(containerEl) {
|
|
39
|
+
if (_term || typeof Terminal === 'undefined') return;
|
|
40
|
+
_term = new Terminal({
|
|
41
|
+
theme: {
|
|
42
|
+
background: '#0c0c0e', foreground: '#c9d1d9',
|
|
43
|
+
cursor: '#58a6ff', selectionBackground: 'rgba(94,106,210,0.25)',
|
|
44
|
+
black: '#0c0c0e', red: '#ff4444', green: '#3fb950',
|
|
45
|
+
yellow: '#d29922', blue: '#58a6ff', magenta: '#bc8cff',
|
|
46
|
+
cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681',
|
|
47
|
+
},
|
|
48
|
+
fontFamily: '"JetBrains Mono","SF Mono",Consolas,monospace',
|
|
49
|
+
fontSize: 12, lineHeight: 1.4,
|
|
50
|
+
// PTY output already carries CRLF — converting again would double lines.
|
|
51
|
+
convertEol: false,
|
|
52
|
+
scrollback: 8000, cursorBlink: true,
|
|
53
|
+
});
|
|
54
|
+
if (typeof FitAddon !== 'undefined') {
|
|
55
|
+
_termFit = new FitAddon.FitAddon();
|
|
56
|
+
_term.loadAddon(_termFit);
|
|
57
|
+
}
|
|
58
|
+
_term.open(containerEl);
|
|
59
|
+
if (_termFit) { try { _termFit.fit(); } catch (_e) {} }
|
|
60
|
+
// Keystrokes → PTY
|
|
61
|
+
_term.onData(data => {
|
|
62
|
+
if (_termWs && _termWs.readyState === 1) {
|
|
63
|
+
_termWs.send(JSON.stringify({ t: 'i', d: data }));
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Open a WebSocket to the orchestrator PTY for storyId. */
|
|
69
|
+
function connectWs(storyId) {
|
|
70
|
+
if (_termWs) { try { _termWs.close(); } catch (_e) {} _termWs = null; }
|
|
71
|
+
const tok = orchToken();
|
|
72
|
+
if (!tok) {
|
|
73
|
+
if (_term) _term.writeln('\r\n\x1b[31m✗ No orchestrator token — restart the dashboard\x1b[0m');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
setStatus('connecting');
|
|
77
|
+
const url = ORCH_WS + '/ws/' + encodeURIComponent(storyId) + '?token=' + encodeURIComponent(tok);
|
|
78
|
+
const ws = new WebSocket(url);
|
|
79
|
+
_termWs = ws;
|
|
80
|
+
|
|
81
|
+
ws.onopen = () => { _resize(); };
|
|
82
|
+
ws.onmessage = e => {
|
|
83
|
+
let m;
|
|
84
|
+
try { m = JSON.parse(e.data); } catch { return; }
|
|
85
|
+
if (m.t === 'o' || m.t === 'hist') {
|
|
86
|
+
if (_term) _term.write(m.d);
|
|
87
|
+
} else if (m.t === 's') {
|
|
88
|
+
setStatus(m.s);
|
|
89
|
+
if (m.s === 'done' || m.s === 'exited' || m.s === 'stopped' || m.s === 'error') {
|
|
90
|
+
if (_term) _term.writeln('\r\n\x1b[90m── session ' + m.s + ' ──\x1b[0m');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
ws.onerror = () => { setStatus('error'); };
|
|
95
|
+
ws.onclose = () => { if (_termWs === ws) _termWs = null; };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export function XtermPanel() {
|
|
101
|
+
const { terminal, termStatus } = useStore();
|
|
102
|
+
const containerRef = useRef(null);
|
|
103
|
+
const currentStoryRef = useRef(null);
|
|
104
|
+
|
|
105
|
+
const t = terminal || {};
|
|
106
|
+
const open = !!t.open;
|
|
107
|
+
const minimized = !!t.minimized;
|
|
108
|
+
const fullscreen = !!t.fullscreen;
|
|
109
|
+
const storyId = t.storyId || '';
|
|
110
|
+
const title = t.title || 'Terminal';
|
|
111
|
+
|
|
112
|
+
// Build xterm instance on first open; reconnect when storyId changes.
|
|
113
|
+
// The resize listener is registered here (not inside ensureTerm) so the
|
|
114
|
+
// cleanup return can mirror it on unmount.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!open || !containerRef.current) return;
|
|
117
|
+
ensureTerm(containerRef.current);
|
|
118
|
+
if (_term) { _term.clear(); _resize(); }
|
|
119
|
+
if (storyId && storyId !== currentStoryRef.current) {
|
|
120
|
+
currentStoryRef.current = storyId;
|
|
121
|
+
connectWs(storyId);
|
|
122
|
+
}
|
|
123
|
+
window.addEventListener('resize', _resize);
|
|
124
|
+
return () => window.removeEventListener('resize', _resize);
|
|
125
|
+
}, [open, storyId]);
|
|
126
|
+
|
|
127
|
+
// Resize when entering/leaving fullscreen or on open
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (open) { setTimeout(_resize, 50); }
|
|
130
|
+
}, [open, fullscreen]);
|
|
131
|
+
|
|
132
|
+
// Escape key closes
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
function onKey(e) {
|
|
135
|
+
if (e.key === 'Escape' && open && !minimized) {
|
|
136
|
+
setState({ terminal: { ...t, open: false } });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
window.addEventListener('keydown', onKey);
|
|
140
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
141
|
+
}, [open, minimized, t]);
|
|
142
|
+
|
|
143
|
+
const dotCls = 'term-status-dot ' + (termStatus || '');
|
|
144
|
+
|
|
145
|
+
// ── Actions ──
|
|
146
|
+
const handleMinimize = useCallback(() => {
|
|
147
|
+
setState({ terminal: { ...t, open: true, minimized: true } });
|
|
148
|
+
}, [t]);
|
|
149
|
+
|
|
150
|
+
const handleRestore = useCallback(() => {
|
|
151
|
+
setState({ terminal: { ...t, open: true, minimized: false } });
|
|
152
|
+
setTimeout(_resize, 50);
|
|
153
|
+
}, [t]);
|
|
154
|
+
|
|
155
|
+
const handleClose = useCallback(() => {
|
|
156
|
+
if (_termWs) { try { _termWs.close(); } catch (_e) {} _termWs = null; }
|
|
157
|
+
setState({ terminal: null });
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
const handleStop = useCallback(() => {
|
|
161
|
+
if (storyId) stopSession(storyId);
|
|
162
|
+
}, [storyId]);
|
|
163
|
+
|
|
164
|
+
const handleToggleFull = useCallback(() => {
|
|
165
|
+
setState({ terminal: { ...t, fullscreen: !fullscreen } });
|
|
166
|
+
setTimeout(_resize, 50);
|
|
167
|
+
}, [t, fullscreen]);
|
|
168
|
+
|
|
169
|
+
// ── Pill (minimized state) ──
|
|
170
|
+
const pill = html`
|
|
171
|
+
<div
|
|
172
|
+
class=${'term-pill' + (minimized ? ' show' : '')}
|
|
173
|
+
onClick=${handleRestore}
|
|
174
|
+
title="Restore terminal"
|
|
175
|
+
>
|
|
176
|
+
<span class=${dotCls}></span>
|
|
177
|
+
<span>${title}</span>
|
|
178
|
+
<span class="term-pill-icon">▢</span>
|
|
179
|
+
</div>
|
|
180
|
+
`;
|
|
181
|
+
|
|
182
|
+
// ── Backdrop ──
|
|
183
|
+
const backdrop = html`
|
|
184
|
+
<div class=${'term-backdrop' + (open && !minimized ? ' open' : '')}></div>
|
|
185
|
+
`;
|
|
186
|
+
|
|
187
|
+
// ── Panel ──
|
|
188
|
+
const panelCls = 'term-panel' +
|
|
189
|
+
(open && !minimized ? ' open' : '') +
|
|
190
|
+
(fullscreen ? ' fullscreen' : '');
|
|
191
|
+
|
|
192
|
+
const panel = html`
|
|
193
|
+
<div class=${panelCls}>
|
|
194
|
+
<div class="term-header">
|
|
195
|
+
<div class="term-header-left">
|
|
196
|
+
<div class=${dotCls}></div>
|
|
197
|
+
<span class="term-title">${title}</span>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="term-header-right">
|
|
200
|
+
<button class="term-btn" onClick=${handleToggleFull} title="Toggle full screen">
|
|
201
|
+
⛶ Full
|
|
202
|
+
</button>
|
|
203
|
+
<button class="term-btn" onClick=${handleMinimize} title="Minimize — session keeps running">
|
|
204
|
+
— Min
|
|
205
|
+
</button>
|
|
206
|
+
<button class="term-btn term-stop-btn" onClick=${handleStop} title="End the agent session">
|
|
207
|
+
■ Stop
|
|
208
|
+
</button>
|
|
209
|
+
<button class="term-btn" onClick=${handleClose} title="Close viewer — session keeps running">
|
|
210
|
+
✕ Close
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="term-hint">Click the terminal and type to talk to the agent — Enter sends, Ctrl+C interrupts.</div>
|
|
215
|
+
<div ref=${containerRef} id="term-container"></div>
|
|
216
|
+
</div>
|
|
217
|
+
`;
|
|
218
|
+
|
|
219
|
+
return [backdrop, panel, pill];
|
|
220
|
+
}
|