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 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 => ({ ...s, id: s.name }));
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
- spawnSync('tmux', ['send-keys', '-t', name, message, 'Enter']);
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
- module.exports = { load, saveTelegram, saveSessions, clearSessions, saveResumeCommand, addToHistory, removeFromHistory, getHistory };
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
- <button className="btn btn-sm" onClick={() => onNavigate('create')}>{Icons.plus} New</button>
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
- {sessions.length === 0 ? (
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
- {sessions.map(s => (
538
- <div key={s.id} className={`session-card ${s.status === 'offline' ? 'offline' : ''}`} onClick={() => onNavigate('detail', s)}>
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
- <button className="btn btn-sm" onClick={() => onNavigate('create')}>{Icons.plus} New</button>
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
- {sessions.length === 0 ? (
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
- {sessions.map(s => (
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)' }}>{s.name}</td>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-remote-pilot",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Interactive Claude Code supervisor — spawn and monitor multiple Claude sessions from a single terminal.",
5
5
  "type": "commonjs",
6
6
  "repository": {