@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.
Files changed (33) hide show
  1. package/package.json +7 -1
  2. package/server/dashboard.js +105 -3
  3. package/server/lib/html/client/agents-data.js +27 -0
  4. package/server/lib/html/client/app.js +15 -0
  5. package/server/lib/html/client/components/App.js +211 -0
  6. package/server/lib/html/client/components/OrchPanel.js +293 -0
  7. package/server/lib/html/client/components/Sidebar.js +73 -0
  8. package/server/lib/html/client/components/Topbar.js +53 -0
  9. package/server/lib/html/client/components/XtermPanel.js +220 -0
  10. package/server/lib/html/client/components/shared.js +330 -0
  11. package/server/lib/html/client/icons-client.js +85 -0
  12. package/server/lib/html/client/orchestrator.js +279 -0
  13. package/server/lib/html/client/preact.js +34 -0
  14. package/server/lib/html/client/store.js +91 -0
  15. package/server/lib/html/client/util.js +186 -0
  16. package/server/lib/html/client/views/AgentsView.js +83 -0
  17. package/server/lib/html/client/views/DecisionsView.js +102 -0
  18. package/server/lib/html/client/views/FilesView.js +223 -0
  19. package/server/lib/html/client/views/KanbanView.js +236 -0
  20. package/server/lib/html/client/views/MemoryView.js +157 -0
  21. package/server/lib/html/client/views/MilestonesView.js +136 -0
  22. package/server/lib/html/client/views/OrchestrationView.js +167 -0
  23. package/server/lib/html/client/views/OverviewView.js +221 -0
  24. package/server/lib/html/client/views/PhasesView.js +184 -0
  25. package/server/lib/html/client/views/RoadmapView.js +238 -0
  26. package/server/lib/html/client/views/SprintsView.js +178 -0
  27. package/server/lib/html/client/views/TasksView.js +148 -0
  28. package/server/lib/html/client.js +41 -1775
  29. package/server/lib/html/css.js +264 -44
  30. package/server/lib/html/icons.js +68 -0
  31. package/server/lib/html/shell.js +9 -296
  32. package/server/lib/scanner.js +76 -0
  33. 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
+ }