claude-code-remote-pilot 0.5.10 → 0.5.12
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/lib/WebServer.js +111 -5
- package/lib/config.js +12 -1
- package/lib/ui.html +248 -14
- package/package.json +1 -1
package/lib/WebServer.js
CHANGED
|
@@ -21,6 +21,8 @@ class WebServer {
|
|
|
21
21
|
this._heartbeatInterval = null;
|
|
22
22
|
this._tunnelProcess = null;
|
|
23
23
|
this._tunnelUrl = null;
|
|
24
|
+
this._queues = new Map(); // name → { items: [{id,message}], autoFeed, pendingSend }
|
|
25
|
+
this._sessionMeta = config.getAllSessionMeta(); // name → { emoji, color }
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
_buildAllSessions() {
|
|
@@ -30,7 +32,29 @@ class WebServer {
|
|
|
30
32
|
const offline = history
|
|
31
33
|
.filter(h => !activeNames.has(h.name))
|
|
32
34
|
.map(h => ({ name: h.name, path: h.path, status: 'offline', startedAt: h.lastSeen, resumeAt: null }));
|
|
33
|
-
return [...active, ...offline].map(s =>
|
|
35
|
+
return [...active, ...offline].map(s => {
|
|
36
|
+
const q = this._queues.get(s.name);
|
|
37
|
+
const m = this._sessionMeta[s.name] || {};
|
|
38
|
+
return {
|
|
39
|
+
...s, id: s.name,
|
|
40
|
+
queueLength: q ? q.items.length : 0, autoFeed: q ? q.autoFeed : false,
|
|
41
|
+
emoji: m.emoji || '', color: m.color || '',
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_getQueue(name) {
|
|
47
|
+
if (!this._queues.has(name)) {
|
|
48
|
+
this._queues.set(name, { items: [], autoFeed: false, pendingSend: false });
|
|
49
|
+
}
|
|
50
|
+
return this._queues.get(name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Send a text message followed by Enter. Uses -l (literal) so message content
|
|
54
|
+
// is never misinterpreted as tmux key sequences, then sends Enter separately.
|
|
55
|
+
_tmuxSend(target, message) {
|
|
56
|
+
spawnSync('tmux', ['send-keys', '-t', target, '-l', message]);
|
|
57
|
+
spawnSync('tmux', ['send-keys', '-t', target, 'Enter']);
|
|
34
58
|
}
|
|
35
59
|
|
|
36
60
|
_getOutput(name) {
|
|
@@ -100,9 +124,7 @@ class WebServer {
|
|
|
100
124
|
try {
|
|
101
125
|
const session = this.manager.spawn(dirPath, name, command || 'claude');
|
|
102
126
|
if (initialPrompt) {
|
|
103
|
-
setTimeout(() =>
|
|
104
|
-
spawnSync('tmux', ['send-keys', '-t', session.name, initialPrompt, 'Enter']);
|
|
105
|
-
}, 2000);
|
|
127
|
+
setTimeout(() => this._tmuxSend(session.name, initialPrompt), 2000);
|
|
106
128
|
}
|
|
107
129
|
this._json(res, 201, { ...session, id: session.name });
|
|
108
130
|
} catch (e) {
|
|
@@ -142,11 +164,78 @@ class WebServer {
|
|
|
142
164
|
return this._json(res, 200, { ok: true });
|
|
143
165
|
}
|
|
144
166
|
if (!message) return this._json(res, 400, { error: 'message or key required' });
|
|
145
|
-
|
|
167
|
+
this._tmuxSend(name, message);
|
|
146
168
|
this._json(res, 200, { ok: true });
|
|
147
169
|
});
|
|
148
170
|
}
|
|
149
171
|
|
|
172
|
+
// Queue routes — GET|POST|PATCH /api/sessions/:name/queue
|
|
173
|
+
const queueBaseMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/queue$/);
|
|
174
|
+
if (queueBaseMatch) {
|
|
175
|
+
const name = decodeURIComponent(queueBaseMatch[1]);
|
|
176
|
+
if (req.method === 'GET') {
|
|
177
|
+
const q = this._getQueue(name);
|
|
178
|
+
return this._json(res, 200, { items: q.items, autoFeed: q.autoFeed });
|
|
179
|
+
}
|
|
180
|
+
if (req.method === 'POST') {
|
|
181
|
+
return this._readBody(req, (err, body) => {
|
|
182
|
+
if (err) return this._json(res, 400, { error: err.message });
|
|
183
|
+
if (!body.message) return this._json(res, 400, { error: 'message required' });
|
|
184
|
+
const q = this._getQueue(name);
|
|
185
|
+
const item = { id: crypto.randomBytes(4).toString('hex'), message: body.message };
|
|
186
|
+
q.items.push(item);
|
|
187
|
+
return this._json(res, 201, { item });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (req.method === 'PATCH') {
|
|
191
|
+
return this._readBody(req, (err, body) => {
|
|
192
|
+
if (err) return this._json(res, 400, { error: err.message });
|
|
193
|
+
const q = this._getQueue(name);
|
|
194
|
+
if (typeof body.autoFeed === 'boolean') {
|
|
195
|
+
q.autoFeed = body.autoFeed;
|
|
196
|
+
if (!body.autoFeed) q.pendingSend = false;
|
|
197
|
+
}
|
|
198
|
+
return this._json(res, 200, { autoFeed: q.autoFeed });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// POST /api/sessions/:name/queue/play — send and dequeue first item
|
|
204
|
+
const queuePlayMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/queue\/play$/);
|
|
205
|
+
if (req.method === 'POST' && queuePlayMatch) {
|
|
206
|
+
const name = decodeURIComponent(queuePlayMatch[1]);
|
|
207
|
+
const q = this._queues.get(name);
|
|
208
|
+
if (!q || !q.items.length) return this._json(res, 400, { error: 'Queue is empty' });
|
|
209
|
+
const item = q.items.shift();
|
|
210
|
+
this._tmuxSend(name, item.message);
|
|
211
|
+
return this._json(res, 200, { ok: true, item });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// PATCH /api/sessions/:name/meta — save emoji / color label
|
|
215
|
+
const metaMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/meta$/);
|
|
216
|
+
if (req.method === 'PATCH' && metaMatch) {
|
|
217
|
+
const name = decodeURIComponent(metaMatch[1]);
|
|
218
|
+
return this._readBody(req, (err, body) => {
|
|
219
|
+
if (err) return this._json(res, 400, { error: err.message });
|
|
220
|
+
const patch = {};
|
|
221
|
+
if (body.emoji !== undefined) patch.emoji = String(body.emoji).slice(0, 8);
|
|
222
|
+
if (body.color !== undefined) patch.color = String(body.color).slice(0, 20);
|
|
223
|
+
this._sessionMeta[name] = { ...(this._sessionMeta[name] || {}), ...patch };
|
|
224
|
+
config.saveSessionMeta(name, patch);
|
|
225
|
+
return this._json(res, 200, { ok: true });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// DELETE /api/sessions/:name/queue/:id — remove a specific queued item
|
|
230
|
+
const queueItemMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/queue\/([^/]+)$/);
|
|
231
|
+
if (req.method === 'DELETE' && queueItemMatch) {
|
|
232
|
+
const name = decodeURIComponent(queueItemMatch[1]);
|
|
233
|
+
const id = queueItemMatch[2];
|
|
234
|
+
const q = this._queues.get(name);
|
|
235
|
+
if (q) q.items = q.items.filter(it => it.id !== id);
|
|
236
|
+
return this._json(res, 200, { ok: true });
|
|
237
|
+
}
|
|
238
|
+
|
|
150
239
|
// DELETE /api/sessions/:name
|
|
151
240
|
const killMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
|
|
152
241
|
if (req.method === 'DELETE' && killMatch) {
|
|
@@ -177,6 +266,23 @@ class WebServer {
|
|
|
177
266
|
}
|
|
178
267
|
|
|
179
268
|
_broadcast() {
|
|
269
|
+
// Auto-feed: send the next queued message when a session becomes idle/waiting
|
|
270
|
+
for (const session of this.manager.list()) {
|
|
271
|
+
const q = this._queues.get(session.name);
|
|
272
|
+
if (!q || !q.autoFeed || !q.items.length) {
|
|
273
|
+
if (q && session.status !== 'idle' && session.status !== 'needs-response') q.pendingSend = false;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const isWaiting = session.status === 'idle' || session.status === 'needs-response';
|
|
277
|
+
if (isWaiting && !q.pendingSend) {
|
|
278
|
+
q.pendingSend = true;
|
|
279
|
+
const item = q.items.shift();
|
|
280
|
+
this._tmuxSend(session.name, item.message);
|
|
281
|
+
} else if (!isWaiting) {
|
|
282
|
+
q.pendingSend = false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
180
286
|
if (!this._clients.size) return;
|
|
181
287
|
const payload = `data: ${JSON.stringify(this._buildAllSessions())}\n\n`;
|
|
182
288
|
for (const res of this._clients) {
|
package/lib/config.js
CHANGED
|
@@ -50,4 +50,15 @@ function getHistory() {
|
|
|
50
50
|
return load().sessionHistory || [];
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
function getAllSessionMeta() {
|
|
54
|
+
return load().sessionMeta || {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saveSessionMeta(name, meta) {
|
|
58
|
+
const cfg = load();
|
|
59
|
+
const metas = cfg.sessionMeta || {};
|
|
60
|
+
metas[name] = { ...(metas[name] || {}), ...meta };
|
|
61
|
+
save({ sessionMeta: metas });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { load, saveTelegram, saveSessions, clearSessions, saveResumeCommand, addToHistory, removeFromHistory, getHistory, getAllSessionMeta, saveSessionMeta };
|
package/lib/ui.html
CHANGED
|
@@ -247,6 +247,18 @@ const storage = {
|
|
|
247
247
|
setItem(key, val) { try { localStorage.setItem(key, val); } catch { memStore.set(key, val); } },
|
|
248
248
|
};
|
|
249
249
|
|
|
250
|
+
/* --- Session customization --- */
|
|
251
|
+
const CARD_COLORS = ['', '#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#a855f7', '#ec4899'];
|
|
252
|
+
|
|
253
|
+
const STATUS_SORT_ORDER = { running: 0, 'needs-response': 1, idle: 2, limit: 3, offline: 4, ended: 5 };
|
|
254
|
+
function sortSessions(sessions, by) {
|
|
255
|
+
if (by === 'name') return [...sessions].sort((a, b) => a.name.localeCompare(b.name));
|
|
256
|
+
return [...sessions].sort((a, b) => {
|
|
257
|
+
const ao = STATUS_SORT_ORDER[a.status] ?? 9, bo = STATUS_SORT_ORDER[b.status] ?? 9;
|
|
258
|
+
return ao !== bo ? ao - bo : a.name.localeCompare(b.name);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
250
262
|
/* --- Auth module-level helpers --- */
|
|
251
263
|
let _authToken = storage.getItem('ccp-token') || '';
|
|
252
264
|
let _onUnauth = () => {};
|
|
@@ -505,8 +517,27 @@ function Sidebar({ currentScreen, onNavigate, sessionCount, open, onClose, conne
|
|
|
505
517
|
);
|
|
506
518
|
}
|
|
507
519
|
|
|
520
|
+
/* --- Sort control --- */
|
|
521
|
+
function SortControl({ sortBy, onSortChange }) {
|
|
522
|
+
return (
|
|
523
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
524
|
+
<span style={{ fontSize: 11, color: 'var(--muted)', marginRight: 2 }}>Sort:</span>
|
|
525
|
+
{['status', 'name'].map(opt => (
|
|
526
|
+
<button key={opt} onClick={() => onSortChange(opt)} style={{
|
|
527
|
+
fontSize: 11, padding: '3px 7px', borderRadius: 4, cursor: 'pointer',
|
|
528
|
+
border: '1px solid var(--border)',
|
|
529
|
+
background: sortBy === opt ? 'var(--accent-soft)' : 'var(--bg)',
|
|
530
|
+
color: sortBy === opt ? 'var(--accent)' : 'var(--muted)',
|
|
531
|
+
fontWeight: sortBy === opt ? 600 : 400,
|
|
532
|
+
}}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</button>
|
|
533
|
+
))}
|
|
534
|
+
</div>
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
508
538
|
/* --- Dashboard --- */
|
|
509
|
-
function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
|
|
539
|
+
function DashboardScreen({ onNavigate, sessions, activity, serverStatus, sortBy, onSortChange }) {
|
|
540
|
+
const sorted = sortSessions(sessions, sortBy);
|
|
510
541
|
const running = sessions.filter(s => s.status === 'running');
|
|
511
542
|
const active = sessions.filter(s => s.status !== 'offline');
|
|
512
543
|
const uptime = serverStatus ? formatUptime(serverStatus.startedAt) : '—';
|
|
@@ -523,10 +554,13 @@ function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
|
|
|
523
554
|
|
|
524
555
|
<div className="section-header">
|
|
525
556
|
<h2 className="section-title">Sessions</h2>
|
|
526
|
-
<
|
|
557
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
558
|
+
<SortControl sortBy={sortBy} onSortChange={onSortChange} />
|
|
559
|
+
<button className="btn btn-sm" onClick={() => onNavigate('create')}>{Icons.plus} New</button>
|
|
560
|
+
</div>
|
|
527
561
|
</div>
|
|
528
562
|
|
|
529
|
-
{
|
|
563
|
+
{sorted.length === 0 ? (
|
|
530
564
|
<div className="empty-state">
|
|
531
565
|
<div className="empty-state-icon">⌘</div>
|
|
532
566
|
<div className="empty-state-title">No sessions yet</div>
|
|
@@ -534,11 +568,13 @@ function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
|
|
|
534
568
|
</div>
|
|
535
569
|
) : (
|
|
536
570
|
<div className="session-cards">
|
|
537
|
-
{
|
|
538
|
-
<div key={s.id} className={`session-card ${s.status === 'offline' ? 'offline' : ''}`}
|
|
571
|
+
{sorted.map(s => (
|
|
572
|
+
<div key={s.id} className={`session-card ${s.status === 'offline' ? 'offline' : ''}`}
|
|
573
|
+
style={s.color ? { borderLeft: `4px solid ${s.color}` } : {}}
|
|
574
|
+
onClick={() => onNavigate('detail', s)}>
|
|
539
575
|
<div className="session-card-header">
|
|
540
576
|
<div>
|
|
541
|
-
<div className="session-card-name">{s.name}</div>
|
|
577
|
+
<div className="session-card-name">{s.emoji ? <span style={{ marginRight: 5 }}>{s.emoji}</span> : null}{s.name}</div>
|
|
542
578
|
<div className="session-card-meta" style={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.path}</div>
|
|
543
579
|
</div>
|
|
544
580
|
<StatusPill status={s.status} />
|
|
@@ -547,6 +583,11 @@ function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
|
|
|
547
583
|
<div className="session-card-field"><div className="session-card-field-label">Started</div><div className="session-card-field-value">{relativeTime(s.startedAt)}</div></div>
|
|
548
584
|
<div className="session-card-field"><div className="session-card-field-label">Tokens</div><div className="session-card-field-value">{formatTokens(s.tokens)}</div></div>
|
|
549
585
|
</div>
|
|
586
|
+
{(s.queueLength > 0 || s.autoFeed) && (
|
|
587
|
+
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', fontSize: 11, color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
588
|
+
{s.autoFeed ? '⚡' : '⏳'} {s.queueLength} message{s.queueLength !== 1 ? 's' : ''} queued{s.autoFeed ? ' · auto-feed' : ''}
|
|
589
|
+
</div>
|
|
590
|
+
)}
|
|
550
591
|
</div>
|
|
551
592
|
))}
|
|
552
593
|
</div>
|
|
@@ -579,6 +620,8 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
|
579
620
|
const [output, setOutput] = useState('');
|
|
580
621
|
const [msg, setMsg] = useState('');
|
|
581
622
|
const [sending, setSending] = useState(false);
|
|
623
|
+
const [emoji, setEmoji] = useState(session.emoji || '');
|
|
624
|
+
const [color, setColor] = useState(session.color || '');
|
|
582
625
|
const [killing, setKilling] = useState(false);
|
|
583
626
|
const [killError, setKillError] = useState('');
|
|
584
627
|
const [respawning, setRespawning] = useState(false);
|
|
@@ -598,6 +641,20 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
|
598
641
|
}).catch(() => {});
|
|
599
642
|
};
|
|
600
643
|
|
|
644
|
+
// Sync label fields when navigating between sessions
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
setEmoji(session.emoji || '');
|
|
647
|
+
setColor(session.color || '');
|
|
648
|
+
}, [session.name]);
|
|
649
|
+
|
|
650
|
+
const saveMeta = useCallback((e, c) => {
|
|
651
|
+
apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/meta`, {
|
|
652
|
+
method: 'PATCH',
|
|
653
|
+
headers: { 'Content-Type': 'application/json' },
|
|
654
|
+
body: JSON.stringify({ emoji: e, color: c }),
|
|
655
|
+
}).catch(() => {});
|
|
656
|
+
}, [session.name]);
|
|
657
|
+
|
|
601
658
|
// Auto-focus input on mount and when session changes
|
|
602
659
|
useEffect(() => {
|
|
603
660
|
if (!isOffline) setTimeout(() => inputRef.current?.focus(), 50);
|
|
@@ -747,7 +804,7 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
|
747
804
|
|
|
748
805
|
<div className="detail-header">
|
|
749
806
|
<div>
|
|
750
|
-
<h2 className="detail-title">{session.name}</h2>
|
|
807
|
+
<h2 className="detail-title">{emoji ? <span style={{ marginRight: 6 }}>{emoji}</span> : null}{session.name}</h2>
|
|
751
808
|
<div className="detail-meta" style={{ maxWidth: 480, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{session.path}</div>
|
|
752
809
|
</div>
|
|
753
810
|
<div className="detail-actions">
|
|
@@ -844,9 +901,33 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
|
844
901
|
<div className="info-row"><span className="info-label">Resumes</span><span className="info-value" style={{ fontSize: 11, color: 'var(--warning)' }}>{relativeTime(session.resumeAt)}</span></div>
|
|
845
902
|
)}
|
|
846
903
|
<div className="info-row"><span className="info-label">tmux</span><span className="info-value" style={{ fontSize: 11 }}>{session.name}</span></div>
|
|
904
|
+
<div className="info-row" style={{ alignItems: 'flex-start', paddingTop: 10, marginTop: 4, borderTop: '1px solid var(--border)' }}>
|
|
905
|
+
<span className="info-label" style={{ paddingTop: 4 }}>Label</span>
|
|
906
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
907
|
+
<input
|
|
908
|
+
value={emoji}
|
|
909
|
+
onChange={e => setEmoji(e.target.value)}
|
|
910
|
+
onBlur={() => saveMeta(emoji, color)}
|
|
911
|
+
maxLength={8}
|
|
912
|
+
placeholder="😀"
|
|
913
|
+
style={{ width: 44, textAlign: 'center', fontSize: 18, padding: '2px 4px', border: '1px solid var(--border)', borderRadius: 4, background: 'var(--bg)', color: 'var(--fg)', cursor: 'text' }}
|
|
914
|
+
/>
|
|
915
|
+
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
916
|
+
{CARD_COLORS.map(c => (
|
|
917
|
+
<button key={c || 'none'} onClick={() => { setColor(c); saveMeta(emoji, c); }} title={c || 'No color'} style={{
|
|
918
|
+
width: 16, height: 16, borderRadius: '50%', padding: 0, cursor: 'pointer', flexShrink: 0,
|
|
919
|
+
background: c || 'transparent',
|
|
920
|
+
border: color === c ? '2px solid var(--fg)' : c ? '1px solid oklch(0% 0 0 / 0.2)' : '1px dashed var(--muted)',
|
|
921
|
+
}} />
|
|
922
|
+
))}
|
|
923
|
+
</div>
|
|
924
|
+
</div>
|
|
925
|
+
</div>
|
|
847
926
|
</div>
|
|
848
927
|
</div>
|
|
849
928
|
</div>
|
|
929
|
+
|
|
930
|
+
<QueuePanel session={session} />
|
|
850
931
|
</div>
|
|
851
932
|
);
|
|
852
933
|
}
|
|
@@ -927,14 +1008,18 @@ function CreateSessionScreen({ onBack, onCreated }) {
|
|
|
927
1008
|
}
|
|
928
1009
|
|
|
929
1010
|
/* --- Sessions List --- */
|
|
930
|
-
function SessionsScreen({ sessions, onNavigate }) {
|
|
1011
|
+
function SessionsScreen({ sessions, onNavigate, sortBy, onSortChange }) {
|
|
1012
|
+
const sorted = sortSessions(sessions, sortBy);
|
|
931
1013
|
return (
|
|
932
1014
|
<div>
|
|
933
1015
|
<div className="section-header">
|
|
934
1016
|
<h2 className="section-title">All Sessions</h2>
|
|
935
|
-
<
|
|
1017
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
1018
|
+
<SortControl sortBy={sortBy} onSortChange={onSortChange} />
|
|
1019
|
+
<button className="btn btn-sm" onClick={() => onNavigate('create')}>{Icons.plus} New</button>
|
|
1020
|
+
</div>
|
|
936
1021
|
</div>
|
|
937
|
-
{
|
|
1022
|
+
{sorted.length === 0 ? (
|
|
938
1023
|
<div className="empty-state"><div className="empty-state-icon">⌘</div><div className="empty-state-title">No sessions</div></div>
|
|
939
1024
|
) : (
|
|
940
1025
|
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)' }}>
|
|
@@ -945,12 +1030,14 @@ function SessionsScreen({ sessions, onNavigate }) {
|
|
|
945
1030
|
))}</tr>
|
|
946
1031
|
</thead>
|
|
947
1032
|
<tbody>
|
|
948
|
-
{
|
|
1033
|
+
{sorted.map(s => (
|
|
949
1034
|
<tr key={s.id} style={{ cursor:'pointer',opacity:s.status==='offline'?0.6:1,transition:'background 0.12s' }}
|
|
950
1035
|
onClick={() => onNavigate('detail', s)}
|
|
951
1036
|
onMouseEnter={e => e.currentTarget.style.background='var(--surface-hover)'}
|
|
952
1037
|
onMouseLeave={e => e.currentTarget.style.background=''}>
|
|
953
|
-
<td style={{ padding:'12px 14px',fontWeight:600,fontSize:13,borderBottom:'1px solid var(--border)'
|
|
1038
|
+
<td style={{ padding:'12px 14px',fontWeight:600,fontSize:13,borderBottom:'1px solid var(--border)',borderLeft: s.color ? `4px solid ${s.color}` : undefined }}>
|
|
1039
|
+
{s.emoji ? <span style={{ marginRight: 5 }}>{s.emoji}</span> : null}{s.name}
|
|
1040
|
+
</td>
|
|
954
1041
|
<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>
|
|
955
1042
|
<td style={{ padding:'12px 14px',borderBottom:'1px solid var(--border)' }}><StatusPill status={s.status} /></td>
|
|
956
1043
|
<td style={{ padding:'12px 14px',fontSize:12,color:'var(--muted)',fontFamily:'var(--font-mono)',borderBottom:'1px solid var(--border)' }}>{relativeTime(s.startedAt)}</td>
|
|
@@ -1015,10 +1102,157 @@ function BroadcastBar({ activeSessions }) {
|
|
|
1015
1102
|
);
|
|
1016
1103
|
}
|
|
1017
1104
|
|
|
1105
|
+
/* --- Queue Panel --- */
|
|
1106
|
+
function QueuePanel({ session }) {
|
|
1107
|
+
const isOffline = session.status === 'offline';
|
|
1108
|
+
const [items, setItems] = useState([]);
|
|
1109
|
+
const [autoFeed, setAutoFeed] = useState(false);
|
|
1110
|
+
const [input, setInput] = useState('');
|
|
1111
|
+
const [busy, setBusy] = useState(false);
|
|
1112
|
+
const inputRef = useRef(null);
|
|
1113
|
+
|
|
1114
|
+
useEffect(() => {
|
|
1115
|
+
let mounted = true;
|
|
1116
|
+
const load = () => {
|
|
1117
|
+
apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue`, { cache: 'no-store' })
|
|
1118
|
+
.then(r => r.json())
|
|
1119
|
+
.then(d => { if (mounted) { setItems(d.items || []); setAutoFeed(!!d.autoFeed); } })
|
|
1120
|
+
.catch(() => {});
|
|
1121
|
+
};
|
|
1122
|
+
load();
|
|
1123
|
+
const t = setInterval(load, 3000);
|
|
1124
|
+
return () => { mounted = false; clearInterval(t); };
|
|
1125
|
+
}, [session.name]);
|
|
1126
|
+
|
|
1127
|
+
const enqueue = async () => {
|
|
1128
|
+
const msg = input.trim();
|
|
1129
|
+
if (!msg || busy) return;
|
|
1130
|
+
setBusy(true);
|
|
1131
|
+
try {
|
|
1132
|
+
const res = await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue`, {
|
|
1133
|
+
method: 'POST',
|
|
1134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1135
|
+
body: JSON.stringify({ message: msg }),
|
|
1136
|
+
});
|
|
1137
|
+
const data = await res.json();
|
|
1138
|
+
if (res.ok) { setItems(prev => [...prev, data.item]); setInput(''); }
|
|
1139
|
+
} finally {
|
|
1140
|
+
setBusy(false);
|
|
1141
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
const play = async () => {
|
|
1146
|
+
if (busy || !items.length || isOffline) return;
|
|
1147
|
+
setBusy(true);
|
|
1148
|
+
try {
|
|
1149
|
+
await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue/play`, { method: 'POST' });
|
|
1150
|
+
setItems(prev => prev.slice(1));
|
|
1151
|
+
} finally { setBusy(false); }
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
const remove = async (id) => {
|
|
1155
|
+
setItems(prev => prev.filter(it => it.id !== id));
|
|
1156
|
+
apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue/${id}`, { method: 'DELETE' }).catch(() => {});
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
const toggleAutoFeed = async () => {
|
|
1160
|
+
if (isOffline) return;
|
|
1161
|
+
const next = !autoFeed;
|
|
1162
|
+
setAutoFeed(next);
|
|
1163
|
+
apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue`, {
|
|
1164
|
+
method: 'PATCH',
|
|
1165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1166
|
+
body: JSON.stringify({ autoFeed: next }),
|
|
1167
|
+
}).catch(() => setAutoFeed(!next));
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
return (
|
|
1171
|
+
<div className="card" style={{ padding: '14px 18px', marginTop: 16 }}>
|
|
1172
|
+
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
|
|
1173
|
+
<span style={{ fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
1174
|
+
Queue
|
|
1175
|
+
{items.length > 0 && (
|
|
1176
|
+
<span style={{ background: 'var(--accent-soft)', color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '1px 6px', borderRadius: 8, fontWeight: 700 }}>
|
|
1177
|
+
{items.length}
|
|
1178
|
+
</span>
|
|
1179
|
+
)}
|
|
1180
|
+
</span>
|
|
1181
|
+
<button
|
|
1182
|
+
onClick={toggleAutoFeed}
|
|
1183
|
+
disabled={isOffline}
|
|
1184
|
+
title={isOffline ? 'Start session to enable auto-feed' : autoFeed ? 'Auto-feed: sends next message when session goes idle' : 'Enable auto-feed'}
|
|
1185
|
+
style={{
|
|
1186
|
+
marginLeft: 'auto', fontSize: 11, padding: '3px 8px', borderRadius: 6, cursor: isOffline ? 'not-allowed' : 'pointer', fontWeight: 600,
|
|
1187
|
+
border: `1px solid ${autoFeed && !isOffline ? 'var(--success)' : 'var(--border)'}`,
|
|
1188
|
+
background: autoFeed && !isOffline ? 'var(--success-soft)' : 'var(--bg)',
|
|
1189
|
+
color: autoFeed && !isOffline ? 'var(--success)' : 'var(--muted)',
|
|
1190
|
+
opacity: isOffline ? 0.5 : 1,
|
|
1191
|
+
}}
|
|
1192
|
+
>
|
|
1193
|
+
{autoFeed && !isOffline ? '⚡ Auto-feed on' : 'Auto-feed off'}
|
|
1194
|
+
</button>
|
|
1195
|
+
</div>
|
|
1196
|
+
|
|
1197
|
+
{items.length === 0 && (
|
|
1198
|
+
<div style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center', padding: '8px 0 12px' }}>No messages queued</div>
|
|
1199
|
+
)}
|
|
1200
|
+
|
|
1201
|
+
{items.length > 0 && (
|
|
1202
|
+
<div style={{ marginBottom: 12, border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', overflow: 'hidden' }}>
|
|
1203
|
+
{items.map((item, i) => (
|
|
1204
|
+
<div key={item.id} style={{
|
|
1205
|
+
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
|
|
1206
|
+
background: i === 0 ? 'var(--accent-soft)' : 'transparent',
|
|
1207
|
+
borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none',
|
|
1208
|
+
}}>
|
|
1209
|
+
{i === 0 ? (
|
|
1210
|
+
<button
|
|
1211
|
+
onClick={play}
|
|
1212
|
+
disabled={busy || isOffline}
|
|
1213
|
+
title={isOffline ? 'Session offline' : 'Send now'}
|
|
1214
|
+
style={{ background: 'none', border: 'none', cursor: busy || isOffline ? 'not-allowed' : 'pointer', color: isOffline ? 'var(--muted)' : 'var(--success)', fontSize: 13, padding: 0, flexShrink: 0, opacity: isOffline ? 0.4 : 1 }}
|
|
1215
|
+
>▶</button>
|
|
1216
|
+
) : (
|
|
1217
|
+
<span style={{ width: 16, flexShrink: 0, textAlign: 'center', fontSize: 11, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>{i + 1}</span>
|
|
1218
|
+
)}
|
|
1219
|
+
<span style={{ flex: 1, fontSize: 12, fontFamily: 'var(--font-mono)', color: i === 0 ? 'var(--fg)' : 'var(--fg-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
1220
|
+
{item.message}
|
|
1221
|
+
</span>
|
|
1222
|
+
<button
|
|
1223
|
+
onClick={() => remove(item.id)}
|
|
1224
|
+
title="Remove"
|
|
1225
|
+
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', fontSize: 13, padding: 0, flexShrink: 0, lineHeight: 1 }}
|
|
1226
|
+
>✕</button>
|
|
1227
|
+
</div>
|
|
1228
|
+
))}
|
|
1229
|
+
</div>
|
|
1230
|
+
)}
|
|
1231
|
+
|
|
1232
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
1233
|
+
<input
|
|
1234
|
+
ref={inputRef}
|
|
1235
|
+
className="form-input"
|
|
1236
|
+
placeholder="Type a message to queue…"
|
|
1237
|
+
value={input}
|
|
1238
|
+
onChange={e => setInput(e.target.value)}
|
|
1239
|
+
onKeyDown={e => e.key === 'Enter' && enqueue()}
|
|
1240
|
+
style={{ flex: 1, fontSize: 12 }}
|
|
1241
|
+
/>
|
|
1242
|
+
<button className="btn btn-sm btn-primary" onClick={enqueue} disabled={busy || !input.trim()}>
|
|
1243
|
+
+ Enqueue
|
|
1244
|
+
</button>
|
|
1245
|
+
</div>
|
|
1246
|
+
</div>
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1018
1250
|
/* --- App --- */
|
|
1019
1251
|
function App() {
|
|
1020
1252
|
const savedTheme = storage.getItem('ccp-theme');
|
|
1021
1253
|
const [dark, setDark] = useState(savedTheme === 'dark');
|
|
1254
|
+
const [sortBy, setSortByState] = useState(storage.getItem('ccp-sort') || 'status');
|
|
1255
|
+
const setSortBy = (v) => { setSortByState(v); storage.setItem('ccp-sort', v); };
|
|
1022
1256
|
const [screen, setScreen] = useState('dashboard');
|
|
1023
1257
|
const [selectedSession, setSelectedSession] = useState(null);
|
|
1024
1258
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
@@ -1127,8 +1361,8 @@ function App() {
|
|
|
1127
1361
|
|
|
1128
1362
|
const renderScreen = () => {
|
|
1129
1363
|
switch (screen) {
|
|
1130
|
-
case 'dashboard': return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} />;
|
|
1131
|
-
case 'sessions': return <SessionsScreen sessions={sessions} onNavigate={navigate} />;
|
|
1364
|
+
case 'dashboard': return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} sortBy={sortBy} onSortChange={setSortBy} />;
|
|
1365
|
+
case 'sessions': return <SessionsScreen sessions={sessions} onNavigate={navigate} sortBy={sortBy} onSortChange={setSortBy} />;
|
|
1132
1366
|
case 'create': return <CreateSessionScreen onBack={() => navigate('dashboard')} onCreated={s => navigate('detail', s)} />;
|
|
1133
1367
|
case 'detail': return <SessionDetailScreen session={selectedSession} onBack={() => navigate('dashboard')} onKilled={() => navigate('sessions')} onRespawned={(respawned) => {
|
|
1134
1368
|
setSessions(prev => {
|
package/package.json
CHANGED