claude-code-remote-pilot 0.3.1 → 0.4.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/CHANGELOG.md +18 -0
- package/README.md +23 -1
- package/bin/claude-pilot.js +20 -2
- package/lib/Watcher.js +2 -2
- package/lib/WebServer.js +171 -0
- package/lib/ui.html +858 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-05-06
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Web dashboard**: `web [port]` REPL command starts a browser UI at `http://127.0.0.1:3742` (auto-opens in default browser).
|
|
7
|
+
- Live session list via SSE (Server-Sent Events) — no polling, no WebSocket dependency
|
|
8
|
+
- Terminal output viewer with 2-second auto-refresh
|
|
9
|
+
- Send message to Claude from the browser
|
|
10
|
+
- Create new sessions from the browser (name, path, optional initial prompt)
|
|
11
|
+
- Kill sessions from the session detail view
|
|
12
|
+
- Dark/light mode, responsive layout (mobile + desktop)
|
|
13
|
+
- Status activity log: tracks transitions between running / idle / needs-response / limit / offline
|
|
14
|
+
- All tmux calls use `spawnSync` array args — shell-injection safe; server binds to `127.0.0.1` only
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Shell injection vulnerability in `Watcher.js`: `tmux send-keys` on auto-resume now uses `spawnSync` with array args instead of shell interpolation.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
3
21
|
## 0.3.1 — 2026-05-06
|
|
4
22
|
|
|
5
23
|
### Changed
|
package/README.md
CHANGED
|
@@ -104,6 +104,7 @@ sudo apt update && sudo apt install tmux
|
|
|
104
104
|
| `spawn <path> [name]` | Start Claude at a path. Name defaults to the directory name. |
|
|
105
105
|
| `list` | One-shot status of all sessions. |
|
|
106
106
|
| `watch` | Live dashboard with offline session history. Press a number to select, `q` to exit. |
|
|
107
|
+
| `web [port]` | Start the web dashboard at `http://127.0.0.1:3742` (or given port). Opens browser automatically. |
|
|
107
108
|
| `attach <name>` | Open a tmux session in the current terminal. |
|
|
108
109
|
| `kill <name>` | Stop a session. |
|
|
109
110
|
| `help` | Show command reference. |
|
|
@@ -111,6 +112,27 @@ sudo apt update && sudo apt install tmux
|
|
|
111
112
|
|
|
112
113
|
---
|
|
113
114
|
|
|
115
|
+
## Web dashboard
|
|
116
|
+
|
|
117
|
+
Type `web` in the REPL to open a browser dashboard:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
claude-pilot> web
|
|
121
|
+
✓ Web dashboard started at http://127.0.0.1:3742
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The dashboard shows all sessions (live and offline), lets you:
|
|
125
|
+
|
|
126
|
+
- View terminal output for each session (auto-refreshes every 2 seconds)
|
|
127
|
+
- Send a message to Claude directly from the browser
|
|
128
|
+
- Spawn new sessions with a name, path, and optional initial prompt
|
|
129
|
+
- Kill sessions
|
|
130
|
+
- See a live activity log of status transitions
|
|
131
|
+
|
|
132
|
+
The server binds to `127.0.0.1` only — not reachable from other machines. Use `web <port>` to use a custom port.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
114
136
|
## Telegram setup
|
|
115
137
|
|
|
116
138
|
Create a bot via `@BotFather` and get your token.
|
|
@@ -173,7 +195,7 @@ Start Claude without `--dangerously-skip-permissions` unless you know what you'r
|
|
|
173
195
|
- [x] Telegram notifications
|
|
174
196
|
- [x] interactive REPL — spawn, watch, attach, kill
|
|
175
197
|
- [x] multi-session support
|
|
176
|
-
- [
|
|
198
|
+
- [x] web dashboard — `web [port]` command, React SPA, SSE live updates
|
|
177
199
|
- [x] persistent session history with offline session display
|
|
178
200
|
- [ ] pluggable notification providers
|
|
179
201
|
- [ ] safety / policy engine
|
package/bin/claude-pilot.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const readline = require('readline');
|
|
8
8
|
const SessionManager = require('../lib/SessionManager');
|
|
9
|
+
const WebServer = require('../lib/WebServer');
|
|
9
10
|
const config = require('../lib/config');
|
|
10
11
|
|
|
11
12
|
// ─── dependency checks ────────────────────────────────────────────────────────
|
|
@@ -318,6 +319,7 @@ const HELP = `
|
|
|
318
319
|
spawn <path> [name] Start Claude at path (name defaults to dir name)
|
|
319
320
|
list Show all sessions
|
|
320
321
|
watch Live session monitor (q to exit)
|
|
322
|
+
web [port] Start web dashboard (default port 3742)
|
|
321
323
|
attach <name> Open tmux session in this terminal
|
|
322
324
|
kill <name> Stop a session
|
|
323
325
|
resume [message] Show or set the message sent after a limit resets
|
|
@@ -370,9 +372,9 @@ ${HELP}`);
|
|
|
370
372
|
|
|
371
373
|
const cwd = process.cwd();
|
|
372
374
|
const defaultName = path.basename(cwd);
|
|
373
|
-
const mount = await question(setupRl, `Mount current directory as a session? (${defaultName}) [
|
|
375
|
+
const mount = await question(setupRl, `Mount current directory as a session? (${defaultName}) [y/N] `);
|
|
374
376
|
|
|
375
|
-
if (
|
|
377
|
+
if (mount === 'y' || mount === 'yes') {
|
|
376
378
|
const rawName = await questionRaw(setupRl, `Session name [${defaultName}]: `);
|
|
377
379
|
const session = manager.spawn(cwd, rawName || defaultName);
|
|
378
380
|
console.log(` ✓ "${session.name}" started at ${session.path}`);
|
|
@@ -428,6 +430,22 @@ ${HELP}`);
|
|
|
428
430
|
startWatch(manager, replRl);
|
|
429
431
|
return;
|
|
430
432
|
}
|
|
433
|
+
case 'web': {
|
|
434
|
+
const port = parseInt(args[0]) || 3742;
|
|
435
|
+
let webServer = manager._webServer;
|
|
436
|
+
if (webServer) {
|
|
437
|
+
console.log(` Web dashboard already running at http://127.0.0.1:${webServer.port}`);
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
webServer = new WebServer(manager, port);
|
|
441
|
+
manager._webServer = webServer;
|
|
442
|
+
webServer.start();
|
|
443
|
+
const url = `http://127.0.0.1:${port}`;
|
|
444
|
+
console.log(` ✓ Web dashboard started at ${url}`);
|
|
445
|
+
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
446
|
+
spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
431
449
|
case 'attach': {
|
|
432
450
|
if (!args[0]) { console.log(' Usage: attach <name>'); break; }
|
|
433
451
|
replRl.pause();
|
package/lib/Watcher.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
const { execSync } = require('child_process');
|
|
2
|
+
const { execSync, spawnSync } = require('child_process');
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const notifier = require('./notifier');
|
|
5
5
|
|
|
@@ -146,7 +146,7 @@ class Watcher {
|
|
|
146
146
|
|
|
147
147
|
await new Promise(r => setTimeout(r, wait * 1000));
|
|
148
148
|
|
|
149
|
-
try {
|
|
149
|
+
try { spawnSync('tmux', ['send-keys', '-t', this.session.name, this.resumeCommand, 'Enter'], { stdio: 'ignore' }); }
|
|
150
150
|
catch {}
|
|
151
151
|
|
|
152
152
|
this.lastResumeAt = Date.now() / 1000;
|
package/lib/WebServer.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
|
|
8
|
+
const STRIP_ANSI = /\x1b\[[0-9;]*[mGKHFABCDJsuhl]|\x1b[()][AB012]/g;
|
|
9
|
+
|
|
10
|
+
class WebServer {
|
|
11
|
+
constructor(manager, port = 3742) {
|
|
12
|
+
this.manager = manager;
|
|
13
|
+
this.port = port;
|
|
14
|
+
this.startedAt = new Date();
|
|
15
|
+
this.server = null;
|
|
16
|
+
this._clients = new Set();
|
|
17
|
+
this._broadcastInterval = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_buildAllSessions() {
|
|
21
|
+
const active = this.manager.list();
|
|
22
|
+
const activeNames = new Set(active.map(s => s.name));
|
|
23
|
+
const history = config.getHistory();
|
|
24
|
+
const offline = history
|
|
25
|
+
.filter(h => !activeNames.has(h.name))
|
|
26
|
+
.map(h => ({ name: h.name, path: h.path, status: 'offline', startedAt: h.lastSeen, resumeAt: null }));
|
|
27
|
+
return [...active, ...offline].map(s => ({ ...s, id: s.name }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_getOutput(name) {
|
|
31
|
+
const result = spawnSync('tmux', ['capture-pane', '-pt', name, '-S', '-500'], { encoding: 'utf8' });
|
|
32
|
+
return result.stdout ? result.stdout.replace(STRIP_ANSI, '') : '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_json(res, code, data) {
|
|
36
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
37
|
+
res.end(JSON.stringify(data));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_readBody(req, cb) {
|
|
41
|
+
let raw = '';
|
|
42
|
+
req.on('data', d => raw += d);
|
|
43
|
+
req.on('end', () => {
|
|
44
|
+
try { cb(null, JSON.parse(raw || '{}')); }
|
|
45
|
+
catch { cb(new Error('Invalid JSON')); }
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_handleApi(req, res, pathname) {
|
|
50
|
+
// GET /api/sessions
|
|
51
|
+
if (req.method === 'GET' && pathname === '/api/sessions') {
|
|
52
|
+
return this._json(res, 200, this._buildAllSessions());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// GET /api/status
|
|
56
|
+
if (req.method === 'GET' && pathname === '/api/status') {
|
|
57
|
+
return this._json(res, 200, {
|
|
58
|
+
startedAt: this.startedAt,
|
|
59
|
+
port: this.port,
|
|
60
|
+
activeSessions: this.manager.list().length,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// POST /api/sessions — spawn
|
|
65
|
+
if (req.method === 'POST' && pathname === '/api/sessions') {
|
|
66
|
+
return this._readBody(req, (err, body) => {
|
|
67
|
+
if (err) return this._json(res, 400, { error: err.message });
|
|
68
|
+
const { name, path: dirPath, prompt: initialPrompt } = body;
|
|
69
|
+
try {
|
|
70
|
+
const session = this.manager.spawn(dirPath, name);
|
|
71
|
+
if (initialPrompt) {
|
|
72
|
+
setTimeout(() => {
|
|
73
|
+
spawnSync('tmux', ['send-keys', '-t', session.name, initialPrompt, 'Enter']);
|
|
74
|
+
}, 2000);
|
|
75
|
+
}
|
|
76
|
+
this._json(res, 201, { ...session, id: session.name });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
this._json(res, 400, { error: e.message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// GET /api/sessions/:name/output
|
|
84
|
+
const outputMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/output$/);
|
|
85
|
+
if (req.method === 'GET' && outputMatch) {
|
|
86
|
+
const name = decodeURIComponent(outputMatch[1]);
|
|
87
|
+
return this._json(res, 200, { output: this._getOutput(name) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// POST /api/sessions/:name/send
|
|
91
|
+
const sendMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/send$/);
|
|
92
|
+
if (req.method === 'POST' && sendMatch) {
|
|
93
|
+
const name = decodeURIComponent(sendMatch[1]);
|
|
94
|
+
return this._readBody(req, (err, body) => {
|
|
95
|
+
if (err) return this._json(res, 400, { error: err.message });
|
|
96
|
+
const { message } = body;
|
|
97
|
+
if (!message) return this._json(res, 400, { error: 'message required' });
|
|
98
|
+
spawnSync('tmux', ['send-keys', '-t', name, message, 'Enter']);
|
|
99
|
+
this._json(res, 200, { ok: true });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// DELETE /api/sessions/:name
|
|
104
|
+
const killMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
|
|
105
|
+
if (req.method === 'DELETE' && killMatch) {
|
|
106
|
+
const name = decodeURIComponent(killMatch[1]);
|
|
107
|
+
try {
|
|
108
|
+
this.manager.kill(name);
|
|
109
|
+
return this._json(res, 200, { ok: true });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return this._json(res, 400, { error: e.message });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this._json(res, 404, { error: 'Not found' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_broadcast() {
|
|
119
|
+
if (!this._clients.size) return;
|
|
120
|
+
const payload = `data: ${JSON.stringify(this._buildAllSessions())}\n\n`;
|
|
121
|
+
for (const res of this._clients) {
|
|
122
|
+
try { res.write(payload); } catch { this._clients.delete(res); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
start() {
|
|
127
|
+
const uiHtml = fs.readFileSync(path.join(__dirname, 'ui.html'), 'utf8');
|
|
128
|
+
|
|
129
|
+
this.server = http.createServer((req, res) => {
|
|
130
|
+
const url = new URL(req.url, `http://127.0.0.1:${this.port}`);
|
|
131
|
+
const pathname = url.pathname;
|
|
132
|
+
|
|
133
|
+
if (pathname === '/' || pathname === '/index.html') {
|
|
134
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
135
|
+
return res.end(uiHtml);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (pathname === '/events') {
|
|
139
|
+
res.writeHead(200, {
|
|
140
|
+
'Content-Type': 'text/event-stream',
|
|
141
|
+
'Cache-Control': 'no-cache',
|
|
142
|
+
'Connection': 'keep-alive',
|
|
143
|
+
});
|
|
144
|
+
this._clients.add(res);
|
|
145
|
+
res.write(`data: ${JSON.stringify(this._buildAllSessions())}\n\n`);
|
|
146
|
+
req.on('close', () => this._clients.delete(res));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (pathname.startsWith('/api/')) {
|
|
151
|
+
return this._handleApi(req, res, pathname);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
res.writeHead(404);
|
|
155
|
+
res.end('Not found');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
this._broadcastInterval = setInterval(() => this._broadcast(), 3000);
|
|
159
|
+
this.server.listen(this.port, '127.0.0.1');
|
|
160
|
+
return this.port;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
stop() {
|
|
164
|
+
clearInterval(this._broadcastInterval);
|
|
165
|
+
for (const res of this._clients) { try { res.end(); } catch {} }
|
|
166
|
+
this._clients.clear();
|
|
167
|
+
if (this.server) this.server.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = WebServer;
|
package/lib/ui.html
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><script>(function(){
|
|
4
|
+
function makeStore(){
|
|
5
|
+
var data={};
|
|
6
|
+
var api={getItem:function(k){return Object.prototype.hasOwnProperty.call(data,k)?data[k]:null;},setItem:function(k,v){data[k]=String(v);},removeItem:function(k){delete data[k];},clear:function(){data={};},key:function(i){return Object.keys(data)[i]||null;}};
|
|
7
|
+
Object.defineProperty(api,'length',{get:function(){return Object.keys(data).length;}});
|
|
8
|
+
return api;
|
|
9
|
+
}
|
|
10
|
+
function tryShim(name){var works=false;try{works=!!window[name]&&typeof window[name].getItem==='function';void window[name].length;}catch(_){works=false;}if(works)return;try{Object.defineProperty(window,name,{configurable:true,value:makeStore()});}catch(_){try{window[name]=makeStore();}catch(__){}}};
|
|
11
|
+
tryShim('localStorage');tryShim('sessionStorage');
|
|
12
|
+
})();</script>
|
|
13
|
+
<meta charset="utf-8">
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
15
|
+
<title>Claude Code Pilot — Dashboard</title>
|
|
16
|
+
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" crossorigin="anonymous"></script>
|
|
17
|
+
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" crossorigin="anonymous"></script>
|
|
18
|
+
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin="anonymous"></script>
|
|
19
|
+
<style>
|
|
20
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
|
21
|
+
|
|
22
|
+
:root {
|
|
23
|
+
--bg: oklch(97% 0.018 70);
|
|
24
|
+
--surface: oklch(99% 0.008 70);
|
|
25
|
+
--surface-hover: oklch(96% 0.016 70);
|
|
26
|
+
--fg: oklch(22% 0.02 50);
|
|
27
|
+
--fg-secondary: oklch(35% 0.022 50);
|
|
28
|
+
--muted: oklch(50% 0.018 50);
|
|
29
|
+
--border: oklch(90% 0.014 70);
|
|
30
|
+
--accent: oklch(64% 0.13 28);
|
|
31
|
+
--accent-soft: oklch(64% 0.13 28 / 0.08);
|
|
32
|
+
--accent-medium: oklch(64% 0.13 28 / 0.15);
|
|
33
|
+
--success: oklch(62% 0.14 145);
|
|
34
|
+
--success-soft: oklch(62% 0.14 145 / 0.1);
|
|
35
|
+
--warning: oklch(68% 0.16 75);
|
|
36
|
+
--warning-soft: oklch(68% 0.16 75 / 0.1);
|
|
37
|
+
--error: oklch(58% 0.20 25);
|
|
38
|
+
--error-soft: oklch(58% 0.20 25 / 0.1);
|
|
39
|
+
--idle: oklch(55% 0.018 250);
|
|
40
|
+
--idle-soft: oklch(55% 0.018 250 / 0.1);
|
|
41
|
+
--font-display: 'Newsreader','Iowan Old Style',Georgia,serif;
|
|
42
|
+
--font-body: -apple-system,BlinkMacSystemFont,'SF Pro Text',system-ui,sans-serif;
|
|
43
|
+
--font-mono: 'SF Mono','JetBrains Mono',ui-monospace,Menlo,monospace;
|
|
44
|
+
--radius-sm: 6px;
|
|
45
|
+
--radius-md: 10px;
|
|
46
|
+
--radius-lg: 14px;
|
|
47
|
+
--shadow-sm: 0 1px 2px oklch(22% 0.02 50 / 0.04);
|
|
48
|
+
--shadow-md: 0 2px 8px oklch(22% 0.02 50 / 0.06);
|
|
49
|
+
--shadow-inner: inset 0 1px 3px oklch(22% 0.02 50 / 0.04);
|
|
50
|
+
--sidebar-w: 240px;
|
|
51
|
+
--header-h: 56px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
[data-theme="dark"] {
|
|
55
|
+
--bg: oklch(18% 0.018 50);
|
|
56
|
+
--surface: oklch(22% 0.02 50);
|
|
57
|
+
--surface-hover: oklch(26% 0.022 50);
|
|
58
|
+
--fg: oklch(95% 0.008 70);
|
|
59
|
+
--fg-secondary: oklch(75% 0.012 70);
|
|
60
|
+
--muted: oklch(55% 0.018 50);
|
|
61
|
+
--border: oklch(32% 0.02 50);
|
|
62
|
+
--accent: oklch(70% 0.14 32);
|
|
63
|
+
--accent-soft: oklch(70% 0.14 32 / 0.12);
|
|
64
|
+
--accent-medium: oklch(70% 0.14 32 / 0.2);
|
|
65
|
+
--success: oklch(68% 0.15 150);
|
|
66
|
+
--success-soft: oklch(68% 0.15 150 / 0.12);
|
|
67
|
+
--warning: oklch(72% 0.16 78);
|
|
68
|
+
--warning-soft: oklch(72% 0.16 78 / 0.12);
|
|
69
|
+
--error: oklch(64% 0.20 28);
|
|
70
|
+
--error-soft: oklch(64% 0.20 28 / 0.12);
|
|
71
|
+
--idle: oklch(60% 0.018 250);
|
|
72
|
+
--idle-soft: oklch(60% 0.018 250 / 0.12);
|
|
73
|
+
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.2);
|
|
74
|
+
--shadow-md: 0 2px 8px oklch(0% 0 0 / 0.25);
|
|
75
|
+
--shadow-inner: inset 0 1px 3px oklch(0% 0 0 / 0.15);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
body { font-family:var(--font-body);background:var(--bg);color:var(--fg);line-height:1.5;font-size:14px;-webkit-font-smoothing:antialiased; }
|
|
79
|
+
|
|
80
|
+
#root { height:100vh;display:flex;flex-direction:column; }
|
|
81
|
+
.app-shell { display:flex;flex-direction:column;height:100vh;overflow:hidden; }
|
|
82
|
+
|
|
83
|
+
.sidebar { position:fixed;top:0;left:0;width:100%;height:auto;max-height:85vh;background:var(--surface);border-bottom:1px solid var(--border);z-index:100;transform:translateY(-100%);transition:transform 0.25s ease;border-radius:0 0 var(--radius-lg) var(--radius-lg);overflow-y:auto; }
|
|
84
|
+
.sidebar.open { transform:translateY(0); }
|
|
85
|
+
.sidebar-overlay { position:fixed;inset:0;background:oklch(0% 0 0 / 0.3);z-index:99;opacity:0;pointer-events:none;transition:opacity 0.25s ease; }
|
|
86
|
+
.sidebar-overlay.open { opacity:1;pointer-events:auto; }
|
|
87
|
+
.sidebar-header { padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px; }
|
|
88
|
+
.sidebar-close { margin-left:auto;background:none;border:none;cursor:pointer;color:var(--muted);padding:4px;display:flex; }
|
|
89
|
+
.logo { display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--fg); }
|
|
90
|
+
.logo-mark { width:28px;height:28px;background:var(--accent);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:var(--surface);font-weight:700;font-size:13px; }
|
|
91
|
+
.logo-text { font-family:var(--font-display);font-size:16px;font-weight:600;letter-spacing:-0.01em; }
|
|
92
|
+
.logo-badge { font-size:10px;font-family:var(--font-mono);color:var(--muted);background:var(--accent-soft);padding:2px 6px;border-radius:4px; }
|
|
93
|
+
.sidebar-nav { padding:12px 10px; }
|
|
94
|
+
.nav-section-label { font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);padding:8px 8px 4px;margin-top:8px; }
|
|
95
|
+
.nav-item { display:flex;align-items:center;gap:10px;padding:10px;border-radius:var(--radius-sm);color:var(--fg-secondary);cursor:pointer;font-size:14px;font-weight:500;transition:background 0.15s,color 0.15s;user-select:none;border:none;background:none;width:100%;text-align:left; }
|
|
96
|
+
.nav-item:hover { background:var(--surface-hover);color:var(--fg); }
|
|
97
|
+
.nav-item.active { background:var(--accent-soft);color:var(--fg);font-weight:600; }
|
|
98
|
+
.nav-icon { width:18px;height:18px;opacity:0.7;flex-shrink:0; }
|
|
99
|
+
.nav-item.active .nav-icon { opacity:1; }
|
|
100
|
+
.nav-count { margin-left:auto;font-size:11px;font-family:var(--font-mono);color:var(--muted);background:var(--surface-hover);padding:1px 6px;border-radius:10px;min-width:20px;text-align:center; }
|
|
101
|
+
.sidebar-footer { padding:12px 10px;border-top:1px solid var(--border);margin-top:auto; }
|
|
102
|
+
|
|
103
|
+
.main { flex:1;display:flex;flex-direction:column;overflow:hidden; }
|
|
104
|
+
.mobile-header { height:var(--header-h);border-bottom:1px solid var(--border);background:var(--surface);display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0; }
|
|
105
|
+
.menu-btn { background:none;border:none;cursor:pointer;color:var(--fg);padding:6px;display:flex; }
|
|
106
|
+
.header { height:var(--header-h);border-bottom:1px solid var(--border);background:var(--surface);display:none;align-items:center;padding:0 24px;gap:16px;flex-shrink:0; }
|
|
107
|
+
.mobile-title { font-family:var(--font-display);font-size:15px;font-weight:600;flex:1; }
|
|
108
|
+
.header-title { font-family:var(--font-display);font-size:15px;font-weight:600; }
|
|
109
|
+
.header-spacer { flex:1; }
|
|
110
|
+
.theme-toggle { display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--bg);color:var(--fg-secondary);cursor:pointer;font-size:12px;font-family:var(--font-body);transition:border-color 0.15s; }
|
|
111
|
+
.theme-toggle:hover { border-color:var(--muted); }
|
|
112
|
+
|
|
113
|
+
.content { flex:1;overflow-y:auto;padding:16px; }
|
|
114
|
+
|
|
115
|
+
.session-cards { display:grid;grid-template-columns:1fr;gap:12px; }
|
|
116
|
+
.session-card { background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px;cursor:pointer;transition:border-color 0.15s,box-shadow 0.15s; }
|
|
117
|
+
.session-card:hover { border-color:var(--muted);box-shadow:var(--shadow-md); }
|
|
118
|
+
.session-card.offline { opacity:0.6; }
|
|
119
|
+
.session-card-header { display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px; }
|
|
120
|
+
.session-card-name { font-weight:600;font-size:14px;color:var(--fg);line-height:1.3; }
|
|
121
|
+
.session-card-meta { font-size:12px;color:var(--muted);margin-top:4px; }
|
|
122
|
+
.session-card-body { display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border); }
|
|
123
|
+
.session-card-field { font-size:11px; }
|
|
124
|
+
.session-card-field-label { color:var(--muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:2px; }
|
|
125
|
+
.session-card-field-value { font-family:var(--font-mono);font-size:12px;color:var(--fg-secondary); }
|
|
126
|
+
|
|
127
|
+
.status-pill { display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:3px 8px;border-radius:10px;font-family:var(--font-mono);letter-spacing:0.02em; }
|
|
128
|
+
.status-dot { width:6px;height:6px;border-radius:50%; }
|
|
129
|
+
.status-running { background:var(--success-soft);color:var(--success); }
|
|
130
|
+
.status-running .status-dot { background:var(--success); }
|
|
131
|
+
.status-idle { background:var(--idle-soft);color:var(--idle); }
|
|
132
|
+
.status-idle .status-dot { background:var(--idle); }
|
|
133
|
+
.status-error { background:var(--error-soft);color:var(--error); }
|
|
134
|
+
.status-error .status-dot { background:var(--error); }
|
|
135
|
+
.status-warn { background:var(--warning-soft);color:var(--warning); }
|
|
136
|
+
.status-warn .status-dot { background:var(--warning); }
|
|
137
|
+
|
|
138
|
+
.card { background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-sm); }
|
|
139
|
+
|
|
140
|
+
.btn { display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;font-family:var(--font-body);cursor:pointer;border:1px solid var(--border);background:var(--surface);color:var(--fg);transition:background 0.15s,border-color 0.15s;white-space:nowrap; }
|
|
141
|
+
.btn:hover { background:var(--surface-hover); }
|
|
142
|
+
.btn:disabled { opacity:0.5;cursor:not-allowed; }
|
|
143
|
+
.btn-primary { background:var(--accent);color:oklch(99% 0.008 70);border-color:var(--accent); }
|
|
144
|
+
.btn-primary:hover { filter:brightness(1.05); }
|
|
145
|
+
.btn-sm { padding:4px 10px;font-size:12px; }
|
|
146
|
+
|
|
147
|
+
.terminal { background:oklch(14% 0.015 50);color:oklch(82% 0.015 70);font-family:var(--font-mono);font-size:13px;line-height:1.6;border-radius:var(--radius-md);overflow:hidden;border:1px solid oklch(28% 0.02 50); }
|
|
148
|
+
.terminal-header { display:flex;align-items:center;gap:8px;padding:10px 14px;background:oklch(18% 0.018 50);border-bottom:1px solid oklch(28% 0.02 50); }
|
|
149
|
+
.terminal-dot { width:10px;height:10px;border-radius:50%; }
|
|
150
|
+
.terminal-title { font-size:12px;color:oklch(65% 0.018 50);margin-left:8px; }
|
|
151
|
+
.terminal-body { padding:16px;min-height:320px;max-height:420px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12px;line-height:1.5; }
|
|
152
|
+
|
|
153
|
+
.form-group { margin-bottom:18px; }
|
|
154
|
+
.form-label { display:block;font-size:12px;font-weight:600;color:var(--fg);margin-bottom:6px; }
|
|
155
|
+
.form-hint { font-size:11px;color:var(--muted);margin-top:4px; }
|
|
156
|
+
.form-input,.form-select,.form-textarea { width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg);color:var(--fg);font-size:13px;font-family:var(--font-body);transition:border-color 0.15s; }
|
|
157
|
+
.form-input:focus,.form-select:focus,.form-textarea:focus { outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft); }
|
|
158
|
+
.form-textarea { resize:vertical;min-height:80px; }
|
|
159
|
+
.form-actions { display:flex;gap:10px;justify-content:flex-end;padding-top:20px;border-top:1px solid var(--border);margin-top:8px; }
|
|
160
|
+
|
|
161
|
+
.stat-row { display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:20px; }
|
|
162
|
+
.stat-card { padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-inner); }
|
|
163
|
+
.stat-label { font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:6px; }
|
|
164
|
+
.stat-value { font-size:22px;font-weight:700;font-family:var(--font-display);color:var(--fg);line-height:1.2; }
|
|
165
|
+
.stat-sub { font-size:11px;color:var(--muted);margin-top:4px; }
|
|
166
|
+
|
|
167
|
+
.activity-list { list-style:none; }
|
|
168
|
+
.activity-item { display:flex;align-items:flex-start;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px; }
|
|
169
|
+
.activity-item:last-child { border-bottom:none; }
|
|
170
|
+
.activity-time { font-size:11px;color:var(--muted);font-family:var(--font-mono);white-space:nowrap;margin-top:2px; }
|
|
171
|
+
.activity-text { color:var(--fg-secondary); }
|
|
172
|
+
.activity-text strong { color:var(--fg);font-weight:600; }
|
|
173
|
+
|
|
174
|
+
.section-header { display:flex;align-items:center;justify-content:space-between;margin-bottom:16px; }
|
|
175
|
+
.section-title { font-family:var(--font-display);font-size:15px;font-weight:600; }
|
|
176
|
+
|
|
177
|
+
.back-link { display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--muted);cursor:pointer;margin-bottom:16px;font-weight:500;border:none;background:none;padding:0;font-family:var(--font-body); }
|
|
178
|
+
.back-link:hover { color:var(--fg); }
|
|
179
|
+
|
|
180
|
+
.detail-header { display:flex;flex-direction:column;align-items:flex-start;gap:12px;margin-bottom:20px; }
|
|
181
|
+
.detail-title { font-family:var(--font-display);font-size:18px;font-weight:600;line-height:1.3; }
|
|
182
|
+
.detail-meta { font-size:12px;color:var(--muted);margin-top:4px; }
|
|
183
|
+
.detail-actions { display:flex;gap:8px;margin-left:0;flex-wrap:wrap; }
|
|
184
|
+
|
|
185
|
+
.detail-grid { display:grid;grid-template-columns:1fr;gap:16px; }
|
|
186
|
+
.detail-sidebar .card { padding:16px; }
|
|
187
|
+
.detail-sidebar h3 { font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:12px; }
|
|
188
|
+
|
|
189
|
+
.info-row { display:flex;justify-content:space-between;padding:6px 0;font-size:12px; }
|
|
190
|
+
.info-label { color:var(--muted); }
|
|
191
|
+
.info-value { font-weight:600;font-family:var(--font-mono); }
|
|
192
|
+
|
|
193
|
+
.empty-state { text-align:center;padding:48px 24px;color:var(--muted); }
|
|
194
|
+
.empty-state-icon { font-size:32px;margin-bottom:12px;opacity:0.5; }
|
|
195
|
+
.empty-state-title { font-family:var(--font-display);font-size:15px;font-weight:600;color:var(--fg-secondary);margin-bottom:6px; }
|
|
196
|
+
.empty-state-desc { font-size:13px; }
|
|
197
|
+
|
|
198
|
+
.create-layout { max-width:640px; }
|
|
199
|
+
|
|
200
|
+
.content::-webkit-scrollbar { width:6px; }
|
|
201
|
+
.content::-webkit-scrollbar-track { background:transparent; }
|
|
202
|
+
.content::-webkit-scrollbar-thumb { background:var(--border);border-radius:3px; }
|
|
203
|
+
.terminal-body::-webkit-scrollbar { width:6px; }
|
|
204
|
+
.terminal-body::-webkit-scrollbar-track { background:transparent; }
|
|
205
|
+
.terminal-body::-webkit-scrollbar-thumb { background:oklch(30% 0.02 50);border-radius:3px; }
|
|
206
|
+
|
|
207
|
+
.conn-dot { width:8px;height:8px;border-radius:50%;background:var(--success);display:inline-block; }
|
|
208
|
+
.conn-dot.disconnected { background:var(--error); }
|
|
209
|
+
|
|
210
|
+
@media (min-width:768px) {
|
|
211
|
+
.app-shell { flex-direction:row; }
|
|
212
|
+
.sidebar { position:static;width:var(--sidebar-w);height:100vh;max-height:none;transform:none;border-bottom:none;border-right:1px solid var(--border);border-radius:0;display:flex;flex-direction:column; }
|
|
213
|
+
.sidebar-close,.sidebar-overlay { display:none; }
|
|
214
|
+
.mobile-header { display:none; }
|
|
215
|
+
.header { display:flex; }
|
|
216
|
+
.content { padding:24px; }
|
|
217
|
+
.session-cards { grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); }
|
|
218
|
+
.stat-row { grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:24px; }
|
|
219
|
+
.detail-grid { grid-template-columns:1fr 300px;gap:20px; }
|
|
220
|
+
.detail-header { flex-direction:row;align-items:flex-start;gap:16px;margin-bottom:24px; }
|
|
221
|
+
.detail-title { font-size:20px; }
|
|
222
|
+
.detail-actions { margin-left:auto; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@media (min-width:1200px) {
|
|
226
|
+
.session-cards { grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); }
|
|
227
|
+
}
|
|
228
|
+
</style>
|
|
229
|
+
</head>
|
|
230
|
+
<body>
|
|
231
|
+
<div id="root"></div>
|
|
232
|
+
|
|
233
|
+
<script type="text/babel">
|
|
234
|
+
const { useState, useEffect, useRef, useCallback } = React;
|
|
235
|
+
|
|
236
|
+
const memStore = new Map();
|
|
237
|
+
const storage = {
|
|
238
|
+
getItem(key) { try { return localStorage.getItem(key); } catch { return memStore.has(key) ? memStore.get(key) : null; } },
|
|
239
|
+
setItem(key, val) { try { localStorage.setItem(key, val); } catch { memStore.set(key, val); } },
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
function relativeTime(dateStr) {
|
|
243
|
+
if (!dateStr) return '—';
|
|
244
|
+
const secs = Math.max(0, Math.floor((Date.now() - new Date(dateStr)) / 1000));
|
|
245
|
+
if (secs < 60) return `${secs}s ago`;
|
|
246
|
+
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
|
247
|
+
if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`;
|
|
248
|
+
return `${Math.floor(secs / 86400)}d ago`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function formatUptime(startedAt) {
|
|
252
|
+
if (!startedAt) return '—';
|
|
253
|
+
const secs = Math.max(0, Math.floor((Date.now() - new Date(startedAt)) / 1000));
|
|
254
|
+
const h = Math.floor(secs / 3600);
|
|
255
|
+
const m = Math.floor((secs % 3600) / 60);
|
|
256
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
257
|
+
return `${m}m`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function formatTokens(tokens) {
|
|
261
|
+
if (!tokens) return '—';
|
|
262
|
+
return `↑${tokens.sent} ↓${tokens.received}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function statusCls(status) {
|
|
266
|
+
switch (status) {
|
|
267
|
+
case 'running': return 'status-running';
|
|
268
|
+
case 'needs-response': return 'status-warn';
|
|
269
|
+
case 'limit': return 'status-warn';
|
|
270
|
+
case 'idle': return 'status-idle';
|
|
271
|
+
default: return 'status-idle';
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function statusLabel(status) {
|
|
276
|
+
if (status === 'needs-response') return 'needs input';
|
|
277
|
+
return status;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* --- Icons --- */
|
|
281
|
+
const Icons = {
|
|
282
|
+
dashboard: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="2" width="6" height="6" rx="1"/><rect x="10" y="2" width="6" height="6" rx="1"/><rect x="2" y="10" width="6" height="6" rx="1"/><rect x="10" y="10" width="6" height="6" rx="1"/></svg>,
|
|
283
|
+
sessions: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="3" width="14" height="12" rx="1.5"/><line x1="2" y1="7" x2="16" y2="7"/><line x1="5" y1="11" x2="9" y2="11"/></svg>,
|
|
284
|
+
plus: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="9" y1="3" x2="9" y2="15"/><line x1="3" y1="9" x2="15" y2="9"/></svg>,
|
|
285
|
+
settings: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="9" cy="9" r="3"/><path d="M9 1v2M9 15v2M1 9h2M15 9h2M3.3 3.3l1.4 1.4M13.3 13.3l1.4 1.4M3.3 14.7l1.4-1.4M13.3 4.7l1.4-1.4"/></svg>,
|
|
286
|
+
sun: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="9" cy="9" r="4"/><line x1="9" y1="1" x2="9" y2="3"/><line x1="9" y1="15" x2="9" y2="17"/><line x1="1" y1="9" x2="3" y2="9"/><line x1="15" y1="9" x2="17" y2="9"/></svg>,
|
|
287
|
+
moon: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M15 10.5a6.5 6.5 0 0 1-8-8A6.5 6.5 0 1 0 15 10.5Z"/></svg>,
|
|
288
|
+
arrow: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><polyline points="11,4 6,9 11,14"/></svg>,
|
|
289
|
+
terminal: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><polyline points="4,5 8,9 4,13"/><line x1="10" y1="13" x2="14" y2="13"/></svg>,
|
|
290
|
+
trash: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 5h12M7 5V3.5A1.5 1.5 0 0 1 8.5 2h1A1.5 1.5 0 0 1 11 3.5V5M6 8v5M9 8v5M12 8v5"/><path d="M4.5 5l.7 9.8a1.5 1.5 0 0 0 1.5 1.2h6.6a1.5 1.5 0 0 0 1.5-1.2L13.5 5"/></svg>,
|
|
291
|
+
refresh: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 9a6 6 0 0 1 10.9-2.5M15 9a6 6 0 0 1-10.9 2.5"/><polyline points="3,4 3,9 8,9"/><polyline points="15,14 15,9 10,9"/></svg>,
|
|
292
|
+
menu: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="3" y1="5" x2="15" y2="5"/><line x1="3" y1="9" x2="15" y2="9"/><line x1="3" y1="13" x2="15" y2="13"/></svg>,
|
|
293
|
+
close: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="4" y1="4" x2="14" y2="14"/><line x1="14" y1="4" x2="4" y2="14"/></svg>,
|
|
294
|
+
send: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="2" y1="9" x2="16" y2="9"/><polyline points="10,3 16,9 10,15"/></svg>,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/* --- Status Pill --- */
|
|
298
|
+
function StatusPill({ status }) {
|
|
299
|
+
const cls = statusCls(status);
|
|
300
|
+
const label = statusLabel(status);
|
|
301
|
+
const dim = status === 'offline';
|
|
302
|
+
return (
|
|
303
|
+
<span className={`status-pill ${cls}`} style={dim ? { opacity: 0.55 } : {}}>
|
|
304
|
+
<span className="status-dot" />
|
|
305
|
+
{label}
|
|
306
|
+
</span>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* --- Sidebar --- */
|
|
311
|
+
function Sidebar({ currentScreen, onNavigate, sessionCount, open, onClose, connected }) {
|
|
312
|
+
return (
|
|
313
|
+
<>
|
|
314
|
+
<div className={`sidebar-overlay ${open ? 'open' : ''}`} onClick={onClose} />
|
|
315
|
+
<aside className={`sidebar ${open ? 'open' : ''}`}>
|
|
316
|
+
<div className="sidebar-header">
|
|
317
|
+
<a className="logo" href="#">
|
|
318
|
+
<div className="logo-mark">C</div>
|
|
319
|
+
<span className="logo-text">Code Pilot</span>
|
|
320
|
+
</a>
|
|
321
|
+
<span className="logo-badge">v0.4.0</span>
|
|
322
|
+
<button className="sidebar-close" onClick={onClose}>{Icons.close}</button>
|
|
323
|
+
</div>
|
|
324
|
+
<nav className="sidebar-nav">
|
|
325
|
+
<div className="nav-section-label">Workspace</div>
|
|
326
|
+
<button className={`nav-item ${currentScreen === 'dashboard' ? 'active' : ''}`} onClick={() => { onNavigate('dashboard'); onClose(); }}>
|
|
327
|
+
{Icons.dashboard} Dashboard
|
|
328
|
+
</button>
|
|
329
|
+
<button className={`nav-item ${currentScreen === 'sessions' ? 'active' : ''}`} onClick={() => { onNavigate('sessions'); onClose(); }}>
|
|
330
|
+
{Icons.sessions} Sessions <span className="nav-count">{sessionCount}</span>
|
|
331
|
+
</button>
|
|
332
|
+
<button className={`nav-item ${currentScreen === 'create' ? 'active' : ''}`} onClick={() => { onNavigate('create'); onClose(); }}>
|
|
333
|
+
{Icons.plus} New Session
|
|
334
|
+
</button>
|
|
335
|
+
</nav>
|
|
336
|
+
<div className="sidebar-footer">
|
|
337
|
+
<div style={{ padding: '4px 8px', fontSize: '11px', color: 'var(--muted)', fontFamily: 'var(--font-mono)', display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
338
|
+
<span className={`conn-dot ${connected ? '' : 'disconnected'}`} />
|
|
339
|
+
{connected ? 'Connected' : 'Disconnected'}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
</aside>
|
|
343
|
+
</>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* --- Dashboard Screen --- */
|
|
348
|
+
function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
|
|
349
|
+
const active = sessions.filter(s => s.status !== 'offline');
|
|
350
|
+
const running = sessions.filter(s => s.status === 'running');
|
|
351
|
+
const uptime = serverStatus ? formatUptime(serverStatus.startedAt) : '—';
|
|
352
|
+
const port = serverStatus ? serverStatus.port : 3742;
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div>
|
|
356
|
+
<div className="stat-row">
|
|
357
|
+
<div className="stat-card">
|
|
358
|
+
<div className="stat-label">Running</div>
|
|
359
|
+
<div className="stat-value">{running.length}</div>
|
|
360
|
+
<div className="stat-sub">of {sessions.length} total</div>
|
|
361
|
+
</div>
|
|
362
|
+
<div className="stat-card">
|
|
363
|
+
<div className="stat-label">Active</div>
|
|
364
|
+
<div className="stat-value">{active.length}</div>
|
|
365
|
+
<div className="stat-sub">tmux sessions</div>
|
|
366
|
+
</div>
|
|
367
|
+
<div className="stat-card">
|
|
368
|
+
<div className="stat-label">Supervisor</div>
|
|
369
|
+
<div className="stat-value" style={{ color: 'var(--success)', fontSize: 16 }}>Online</div>
|
|
370
|
+
<div className="stat-sub">:{port}</div>
|
|
371
|
+
</div>
|
|
372
|
+
<div className="stat-card">
|
|
373
|
+
<div className="stat-label">Uptime</div>
|
|
374
|
+
<div className="stat-value" style={{ fontSize: 16 }}>{uptime}</div>
|
|
375
|
+
<div className="stat-sub">since last start</div>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<div className="section-header">
|
|
380
|
+
<h2 className="section-title">Sessions</h2>
|
|
381
|
+
<button className="btn btn-sm" onClick={() => onNavigate('create')}>
|
|
382
|
+
{Icons.plus} New
|
|
383
|
+
</button>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
{sessions.length === 0 ? (
|
|
387
|
+
<div className="empty-state">
|
|
388
|
+
<div className="empty-state-icon">⌘</div>
|
|
389
|
+
<div className="empty-state-title">No sessions yet</div>
|
|
390
|
+
<div className="empty-state-desc">Create a session to get started.</div>
|
|
391
|
+
</div>
|
|
392
|
+
) : (
|
|
393
|
+
<div className="session-cards">
|
|
394
|
+
{sessions.map(s => (
|
|
395
|
+
<div key={s.id} className={`session-card ${s.status === 'offline' ? 'offline' : ''}`} onClick={() => onNavigate('detail', s)}>
|
|
396
|
+
<div className="session-card-header">
|
|
397
|
+
<div>
|
|
398
|
+
<div className="session-card-name">{s.name}</div>
|
|
399
|
+
<div className="session-card-meta" style={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.path}</div>
|
|
400
|
+
</div>
|
|
401
|
+
<StatusPill status={s.status} />
|
|
402
|
+
</div>
|
|
403
|
+
<div className="session-card-body">
|
|
404
|
+
<div className="session-card-field">
|
|
405
|
+
<div className="session-card-field-label">Started</div>
|
|
406
|
+
<div className="session-card-field-value">{relativeTime(s.startedAt)}</div>
|
|
407
|
+
</div>
|
|
408
|
+
<div className="session-card-field">
|
|
409
|
+
<div className="session-card-field-label">Tokens</div>
|
|
410
|
+
<div className="session-card-field-value">{formatTokens(s.tokens)}</div>
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
))}
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
|
|
418
|
+
{activity.length > 0 && (
|
|
419
|
+
<div style={{ marginTop: 24 }}>
|
|
420
|
+
<div className="section-header">
|
|
421
|
+
<h2 className="section-title">Recent Activity</h2>
|
|
422
|
+
</div>
|
|
423
|
+
<div className="card" style={{ padding: '14px 18px' }}>
|
|
424
|
+
<ul className="activity-list">
|
|
425
|
+
{activity.map((a, i) => (
|
|
426
|
+
<li key={i} className="activity-item">
|
|
427
|
+
<span className="activity-time">{relativeTime(a.time)}</span>
|
|
428
|
+
<span className="activity-text">
|
|
429
|
+
<strong>{a.name}</strong> — {a.from} → {a.to}
|
|
430
|
+
</span>
|
|
431
|
+
</li>
|
|
432
|
+
))}
|
|
433
|
+
</ul>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
437
|
+
</div>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/* --- Session Detail Screen --- */
|
|
442
|
+
function SessionDetailScreen({ session, onBack, onKilled }) {
|
|
443
|
+
const [output, setOutput] = useState('');
|
|
444
|
+
const [msg, setMsg] = useState('');
|
|
445
|
+
const [sending, setSending] = useState(false);
|
|
446
|
+
const [killing, setKilling] = useState(false);
|
|
447
|
+
const terminalRef = useRef(null);
|
|
448
|
+
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
if (session.status === 'offline') return;
|
|
451
|
+
const poll = () => {
|
|
452
|
+
fetch(`/api/sessions/${encodeURIComponent(session.name)}/output`)
|
|
453
|
+
.then(r => r.json())
|
|
454
|
+
.then(d => setOutput(d.output || ''))
|
|
455
|
+
.catch(() => {});
|
|
456
|
+
};
|
|
457
|
+
poll();
|
|
458
|
+
const t = setInterval(poll, 2000);
|
|
459
|
+
return () => clearInterval(t);
|
|
460
|
+
}, [session.name, session.status]);
|
|
461
|
+
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
if (terminalRef.current) {
|
|
464
|
+
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
|
465
|
+
}
|
|
466
|
+
}, [output]);
|
|
467
|
+
|
|
468
|
+
const sendMessage = async () => {
|
|
469
|
+
if (!msg.trim() || sending || session.status === 'offline') return;
|
|
470
|
+
setSending(true);
|
|
471
|
+
try {
|
|
472
|
+
await fetch(`/api/sessions/${encodeURIComponent(session.name)}/send`, {
|
|
473
|
+
method: 'POST',
|
|
474
|
+
headers: { 'Content-Type': 'application/json' },
|
|
475
|
+
body: JSON.stringify({ message: msg }),
|
|
476
|
+
});
|
|
477
|
+
setMsg('');
|
|
478
|
+
} catch {
|
|
479
|
+
} finally {
|
|
480
|
+
setSending(false);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const handleKeyDown = (e) => {
|
|
485
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
486
|
+
e.preventDefault();
|
|
487
|
+
sendMessage();
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const handleKill = async () => {
|
|
492
|
+
if (!confirm(`End session "${session.name}"?`)) return;
|
|
493
|
+
setKilling(true);
|
|
494
|
+
try {
|
|
495
|
+
await fetch(`/api/sessions/${encodeURIComponent(session.name)}`, { method: 'DELETE' });
|
|
496
|
+
onKilled();
|
|
497
|
+
} catch {
|
|
498
|
+
setKilling(false);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const isOffline = session.status === 'offline';
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<div>
|
|
506
|
+
<button className="back-link" onClick={onBack}>
|
|
507
|
+
{Icons.arrow} Back
|
|
508
|
+
</button>
|
|
509
|
+
|
|
510
|
+
<div className="detail-header">
|
|
511
|
+
<div>
|
|
512
|
+
<h2 className="detail-title">{session.name}</h2>
|
|
513
|
+
<div className="detail-meta" style={{ maxWidth: 480, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{session.path}</div>
|
|
514
|
+
</div>
|
|
515
|
+
<div className="detail-actions">
|
|
516
|
+
{!isOffline && (
|
|
517
|
+
<button className="btn btn-sm" style={{ color: 'var(--error)' }} onClick={handleKill} disabled={killing}>
|
|
518
|
+
{Icons.trash} {killing ? 'Ending…' : 'End Session'}
|
|
519
|
+
</button>
|
|
520
|
+
)}
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
<div className="detail-grid">
|
|
525
|
+
<div>
|
|
526
|
+
<div className="terminal">
|
|
527
|
+
<div className="terminal-header">
|
|
528
|
+
<span className="terminal-dot" style={{ background: '#ff5f57' }} />
|
|
529
|
+
<span className="terminal-dot" style={{ background: '#febc2e' }} />
|
|
530
|
+
<span className="terminal-dot" style={{ background: '#28c840' }} />
|
|
531
|
+
<span className="terminal-title">{session.name}</span>
|
|
532
|
+
</div>
|
|
533
|
+
<div className="terminal-body" ref={terminalRef}>
|
|
534
|
+
{isOffline
|
|
535
|
+
? <span style={{ color: 'oklch(50% 0.018 50)' }}>Session is offline — no output available.</span>
|
|
536
|
+
: (output || <span style={{ color: 'oklch(50% 0.018 50)' }}>Loading…</span>)
|
|
537
|
+
}
|
|
538
|
+
{!isOffline && output && <span style={{ opacity: 0.5 }}>▊</span>}
|
|
539
|
+
</div>
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
{!isOffline && (
|
|
543
|
+
<div style={{ marginTop: 16 }}>
|
|
544
|
+
<div className="form-group" style={{ marginBottom: 0 }}>
|
|
545
|
+
<label className="form-label">Send message to Claude</label>
|
|
546
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
547
|
+
<input
|
|
548
|
+
className="form-input"
|
|
549
|
+
placeholder="Type a message…"
|
|
550
|
+
value={msg}
|
|
551
|
+
onChange={e => setMsg(e.target.value)}
|
|
552
|
+
onKeyDown={handleKeyDown}
|
|
553
|
+
style={{ flex: 1 }}
|
|
554
|
+
disabled={sending}
|
|
555
|
+
/>
|
|
556
|
+
<button className="btn btn-primary" onClick={sendMessage} disabled={sending || !msg.trim()}>
|
|
557
|
+
{Icons.send} {sending ? 'Sending…' : 'Send'}
|
|
558
|
+
</button>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
|
|
565
|
+
<div className="detail-sidebar">
|
|
566
|
+
<div className="card">
|
|
567
|
+
<h3>Session Info</h3>
|
|
568
|
+
<div className="info-row">
|
|
569
|
+
<span className="info-label">Status</span>
|
|
570
|
+
<StatusPill status={session.status} />
|
|
571
|
+
</div>
|
|
572
|
+
<div className="info-row">
|
|
573
|
+
<span className="info-label">Started</span>
|
|
574
|
+
<span className="info-value" style={{ fontSize: 11 }}>{relativeTime(session.startedAt)}</span>
|
|
575
|
+
</div>
|
|
576
|
+
<div className="info-row">
|
|
577
|
+
<span className="info-label">Tokens</span>
|
|
578
|
+
<span className="info-value" style={{ fontSize: 11 }}>{formatTokens(session.tokens)}</span>
|
|
579
|
+
</div>
|
|
580
|
+
{session.status === 'limit' && session.resumeAt && (
|
|
581
|
+
<div className="info-row">
|
|
582
|
+
<span className="info-label">Resumes</span>
|
|
583
|
+
<span className="info-value" style={{ fontSize: 11, color: 'var(--warning)' }}>
|
|
584
|
+
{relativeTime(session.resumeAt)}
|
|
585
|
+
</span>
|
|
586
|
+
</div>
|
|
587
|
+
)}
|
|
588
|
+
<div className="info-row">
|
|
589
|
+
<span className="info-label">tmux</span>
|
|
590
|
+
<span className="info-value" style={{ fontSize: 11 }}>{session.name}</span>
|
|
591
|
+
</div>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/* --- Create Session Screen --- */
|
|
600
|
+
function CreateSessionScreen({ onBack, onCreated }) {
|
|
601
|
+
const [name, setName] = useState('');
|
|
602
|
+
const [path, setPath] = useState('');
|
|
603
|
+
const [prompt, setPrompt] = useState('');
|
|
604
|
+
const [error, setError] = useState('');
|
|
605
|
+
const [loading, setLoading] = useState(false);
|
|
606
|
+
|
|
607
|
+
const handleCreate = async () => {
|
|
608
|
+
if (!name.trim() || !path.trim()) {
|
|
609
|
+
setError('Session name and working directory are required.');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
setError('');
|
|
613
|
+
setLoading(true);
|
|
614
|
+
try {
|
|
615
|
+
const res = await fetch('/api/sessions', {
|
|
616
|
+
method: 'POST',
|
|
617
|
+
headers: { 'Content-Type': 'application/json' },
|
|
618
|
+
body: JSON.stringify({ name: name.trim(), path: path.trim(), prompt: prompt.trim() || undefined }),
|
|
619
|
+
});
|
|
620
|
+
const data = await res.json();
|
|
621
|
+
if (!res.ok) { setError(data.error || 'Failed to create session.'); return; }
|
|
622
|
+
onCreated(data);
|
|
623
|
+
} catch {
|
|
624
|
+
setError('Network error. Is the supervisor running?');
|
|
625
|
+
} finally {
|
|
626
|
+
setLoading(false);
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
return (
|
|
631
|
+
<div className="create-layout">
|
|
632
|
+
<button className="back-link" onClick={onBack}>
|
|
633
|
+
{Icons.arrow} Back
|
|
634
|
+
</button>
|
|
635
|
+
|
|
636
|
+
<h2 className="section-title" style={{ marginBottom: 20, fontSize: 18 }}>New Session</h2>
|
|
637
|
+
|
|
638
|
+
<div className="form-group">
|
|
639
|
+
<label className="form-label">Session name</label>
|
|
640
|
+
<input className="form-input" placeholder="e.g. refactor-auth-flow" value={name} onChange={e => setName(e.target.value)} />
|
|
641
|
+
<div className="form-hint">Used as the tmux session name. Lowercase with hyphens.</div>
|
|
642
|
+
</div>
|
|
643
|
+
|
|
644
|
+
<div className="form-group">
|
|
645
|
+
<label className="form-label">Working directory</label>
|
|
646
|
+
<input className="form-input" placeholder="~/projects/app" value={path} onChange={e => setPath(e.target.value)} />
|
|
647
|
+
<div className="form-hint">Absolute path or ~ home-relative path where Claude will run.</div>
|
|
648
|
+
</div>
|
|
649
|
+
|
|
650
|
+
<div className="form-group">
|
|
651
|
+
<label className="form-label">Initial prompt <span style={{ fontWeight: 400, color: 'var(--muted)' }}>(optional)</span></label>
|
|
652
|
+
<textarea className="form-textarea" placeholder="What should Claude work on?" value={prompt} onChange={e => setPrompt(e.target.value)} rows={4} />
|
|
653
|
+
<div className="form-hint">Sent to Claude 2 seconds after the session starts.</div>
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
{error && (
|
|
657
|
+
<div style={{ marginBottom: 16, padding: '10px 14px', background: 'var(--error-soft)', color: 'var(--error)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>
|
|
658
|
+
{error}
|
|
659
|
+
</div>
|
|
660
|
+
)}
|
|
661
|
+
|
|
662
|
+
<div className="form-actions">
|
|
663
|
+
<button className="btn" onClick={onBack}>Cancel</button>
|
|
664
|
+
<button className="btn btn-primary" onClick={handleCreate} disabled={loading}>
|
|
665
|
+
{loading ? 'Creating…' : 'Create & Start Session'}
|
|
666
|
+
</button>
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* --- Sessions List Screen --- */
|
|
673
|
+
function SessionsScreen({ sessions, onNavigate }) {
|
|
674
|
+
return (
|
|
675
|
+
<div>
|
|
676
|
+
<div className="section-header">
|
|
677
|
+
<h2 className="section-title">All Sessions</h2>
|
|
678
|
+
<button className="btn btn-sm" onClick={() => onNavigate('create')}>{Icons.plus} New</button>
|
|
679
|
+
</div>
|
|
680
|
+
|
|
681
|
+
{sessions.length === 0 ? (
|
|
682
|
+
<div className="empty-state">
|
|
683
|
+
<div className="empty-state-icon">⌘</div>
|
|
684
|
+
<div className="empty-state-title">No sessions</div>
|
|
685
|
+
<div className="empty-state-desc">Spawn a session to get started.</div>
|
|
686
|
+
</div>
|
|
687
|
+
) : (
|
|
688
|
+
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)' }}>
|
|
689
|
+
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
690
|
+
<thead>
|
|
691
|
+
<tr>
|
|
692
|
+
{['Name', 'Path', 'Status', 'Started', 'Tokens'].map(h => (
|
|
693
|
+
<th key={h} style={{ textAlign: 'left', padding: '10px 14px', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)', borderBottom: '1px solid var(--border)' }}>{h}</th>
|
|
694
|
+
))}
|
|
695
|
+
</tr>
|
|
696
|
+
</thead>
|
|
697
|
+
<tbody>
|
|
698
|
+
{sessions.map(s => (
|
|
699
|
+
<tr key={s.id} style={{ cursor: 'pointer', opacity: s.status === 'offline' ? 0.6 : 1, transition: 'background 0.12s' }}
|
|
700
|
+
onClick={() => onNavigate('detail', s)}
|
|
701
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-hover)'}
|
|
702
|
+
onMouseLeave={e => e.currentTarget.style.background = ''}>
|
|
703
|
+
<td style={{ padding: '12px 14px', fontWeight: 600, fontSize: 13, borderBottom: '1px solid var(--border)' }}>{s.name}</td>
|
|
704
|
+
<td style={{ padding: '12px 14px', fontSize: 12, color: 'var(--muted)', borderBottom: '1px solid var(--border)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.path}</td>
|
|
705
|
+
<td style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}><StatusPill status={s.status} /></td>
|
|
706
|
+
<td style={{ padding: '12px 14px', fontSize: 12, color: 'var(--muted)', fontFamily: 'var(--font-mono)', borderBottom: '1px solid var(--border)' }}>{relativeTime(s.startedAt)}</td>
|
|
707
|
+
<td style={{ padding: '12px 14px', fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--fg-secondary)', borderBottom: '1px solid var(--border)' }}>{formatTokens(s.tokens)}</td>
|
|
708
|
+
</tr>
|
|
709
|
+
))}
|
|
710
|
+
</tbody>
|
|
711
|
+
</table>
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
714
|
+
</div>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/* --- App --- */
|
|
719
|
+
function App() {
|
|
720
|
+
const savedTheme = storage.getItem('ccp-theme');
|
|
721
|
+
const [dark, setDark] = useState(savedTheme === 'dark');
|
|
722
|
+
const [screen, setScreen] = useState('dashboard');
|
|
723
|
+
const [selectedSession, setSelectedSession] = useState(null);
|
|
724
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
725
|
+
const [sessions, setSessions] = useState([]);
|
|
726
|
+
const [serverStatus, setServerStatus] = useState(null);
|
|
727
|
+
const [activity, setActivity] = useState([]);
|
|
728
|
+
const [connected, setConnected] = useState(false);
|
|
729
|
+
const prevStatusRef = useRef({});
|
|
730
|
+
|
|
731
|
+
useEffect(() => {
|
|
732
|
+
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
|
733
|
+
storage.setItem('ccp-theme', dark ? 'dark' : 'light');
|
|
734
|
+
}, [dark]);
|
|
735
|
+
|
|
736
|
+
// SSE for live sessions
|
|
737
|
+
useEffect(() => {
|
|
738
|
+
let es;
|
|
739
|
+
let retryTimer;
|
|
740
|
+
const connect = () => {
|
|
741
|
+
es = new EventSource('/events');
|
|
742
|
+
es.onopen = () => setConnected(true);
|
|
743
|
+
es.onerror = () => {
|
|
744
|
+
setConnected(false);
|
|
745
|
+
es.close();
|
|
746
|
+
retryTimer = setTimeout(connect, 5000);
|
|
747
|
+
};
|
|
748
|
+
es.onmessage = (e) => {
|
|
749
|
+
const incoming = JSON.parse(e.data);
|
|
750
|
+
setSessions(incoming);
|
|
751
|
+
|
|
752
|
+
const now = new Date();
|
|
753
|
+
const prev = prevStatusRef.current;
|
|
754
|
+
const newEntries = [];
|
|
755
|
+
for (const s of incoming) {
|
|
756
|
+
if (prev[s.name] && prev[s.name] !== s.status) {
|
|
757
|
+
newEntries.push({ time: now, name: s.name, from: prev[s.name], to: s.status });
|
|
758
|
+
}
|
|
759
|
+
prev[s.name] = s.status;
|
|
760
|
+
}
|
|
761
|
+
if (newEntries.length) {
|
|
762
|
+
setActivity(a => [...newEntries, ...a].slice(0, 20));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Update selected session data if viewing detail
|
|
766
|
+
setSelectedSession(sel => {
|
|
767
|
+
if (!sel) return sel;
|
|
768
|
+
const updated = incoming.find(s => s.name === sel.name);
|
|
769
|
+
return updated || sel;
|
|
770
|
+
});
|
|
771
|
+
};
|
|
772
|
+
};
|
|
773
|
+
connect();
|
|
774
|
+
return () => { clearTimeout(retryTimer); if (es) es.close(); };
|
|
775
|
+
}, []);
|
|
776
|
+
|
|
777
|
+
// Server status
|
|
778
|
+
useEffect(() => {
|
|
779
|
+
fetch('/api/status').then(r => r.json()).then(setServerStatus).catch(() => {});
|
|
780
|
+
}, []);
|
|
781
|
+
|
|
782
|
+
const navigate = useCallback((target, session) => {
|
|
783
|
+
setScreen(target);
|
|
784
|
+
if (session) setSelectedSession(session);
|
|
785
|
+
setSidebarOpen(false);
|
|
786
|
+
}, []);
|
|
787
|
+
|
|
788
|
+
const screenTitle = {
|
|
789
|
+
dashboard: 'Dashboard',
|
|
790
|
+
sessions: 'Sessions',
|
|
791
|
+
create: 'New Session',
|
|
792
|
+
detail: selectedSession ? selectedSession.name : 'Session',
|
|
793
|
+
}[screen] || 'Dashboard';
|
|
794
|
+
|
|
795
|
+
const renderScreen = () => {
|
|
796
|
+
switch (screen) {
|
|
797
|
+
case 'dashboard':
|
|
798
|
+
return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} />;
|
|
799
|
+
case 'sessions':
|
|
800
|
+
return <SessionsScreen sessions={sessions} onNavigate={navigate} />;
|
|
801
|
+
case 'create':
|
|
802
|
+
return (
|
|
803
|
+
<CreateSessionScreen
|
|
804
|
+
onBack={() => navigate('dashboard')}
|
|
805
|
+
onCreated={(s) => navigate('detail', s)}
|
|
806
|
+
/>
|
|
807
|
+
);
|
|
808
|
+
case 'detail':
|
|
809
|
+
return (
|
|
810
|
+
<SessionDetailScreen
|
|
811
|
+
session={selectedSession}
|
|
812
|
+
onBack={() => navigate('dashboard')}
|
|
813
|
+
onKilled={() => navigate('sessions')}
|
|
814
|
+
/>
|
|
815
|
+
);
|
|
816
|
+
default:
|
|
817
|
+
return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} />;
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
return (
|
|
822
|
+
<div className="app-shell">
|
|
823
|
+
<Sidebar
|
|
824
|
+
currentScreen={screen}
|
|
825
|
+
onNavigate={navigate}
|
|
826
|
+
sessionCount={sessions.length}
|
|
827
|
+
open={sidebarOpen}
|
|
828
|
+
onClose={() => setSidebarOpen(false)}
|
|
829
|
+
connected={connected}
|
|
830
|
+
/>
|
|
831
|
+
<div className="main">
|
|
832
|
+
<div className="mobile-header">
|
|
833
|
+
<button className="menu-btn" onClick={() => setSidebarOpen(true)}>{Icons.menu}</button>
|
|
834
|
+
<span className="mobile-title">{screenTitle}</span>
|
|
835
|
+
<button className="theme-toggle" onClick={() => setDark(d => !d)}>
|
|
836
|
+
{dark ? Icons.sun : Icons.moon}
|
|
837
|
+
</button>
|
|
838
|
+
</div>
|
|
839
|
+
<header className="header">
|
|
840
|
+
<h1 className="header-title">{screenTitle}</h1>
|
|
841
|
+
<div className="header-spacer" />
|
|
842
|
+
<button className="theme-toggle" onClick={() => setDark(d => !d)}>
|
|
843
|
+
{dark ? Icons.sun : Icons.moon}
|
|
844
|
+
{dark ? 'Light' : 'Dark'}
|
|
845
|
+
</button>
|
|
846
|
+
</header>
|
|
847
|
+
<div className="content">
|
|
848
|
+
{renderScreen()}
|
|
849
|
+
</div>
|
|
850
|
+
</div>
|
|
851
|
+
</div>
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
|
856
|
+
</script>
|
|
857
|
+
</body>
|
|
858
|
+
</html>
|
package/package.json
CHANGED