@hmduc16031996/claude-mb-bridge 2.4.0 → 2.4.1
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/dist/index.js +2 -2
- package/dist/server.d.ts +1 -1
- package/dist/server.js +58 -7
- package/package.json +1 -1
- package/public/app.js +2118 -290
- package/public/index.html +204 -5
- package/public/styles.css +5 -5
package/public/app.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// Claude Code Remote -
|
|
2
|
-
// This script adapts the premium UI from the reference repo to the simple bridge server.
|
|
1
|
+
// Claude Code Remote - xterm.js Client
|
|
3
2
|
|
|
3
|
+
// Touch Scroll Manager for natural momentum scrolling on mobile
|
|
4
4
|
class TouchScrollManager {
|
|
5
5
|
constructor(terminal, container) {
|
|
6
6
|
this.terminal = terminal;
|
|
7
7
|
this.container = container;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
|
|
9
|
+
// Physics constants
|
|
10
|
+
this.TIME_CONSTANT = 325; // ms - controls deceleration rate
|
|
11
|
+
this.VELOCITY_THRESHOLD = 10; // px/s - minimum velocity to trigger momentum
|
|
12
|
+
// LINE_HEIGHT = fontSize (14) * lineHeight (1.2) = 16.8, rounded to 17
|
|
10
13
|
this.LINE_HEIGHT = 17;
|
|
11
|
-
this.TAP_THRESHOLD = 10;
|
|
12
|
-
this.TAP_DURATION = 300;
|
|
14
|
+
this.TAP_THRESHOLD = 10; // px - max movement to still count as a tap
|
|
15
|
+
this.TAP_DURATION = 300; // ms - max duration to still count as a tap
|
|
16
|
+
|
|
17
|
+
// State
|
|
13
18
|
this.tracking = false;
|
|
14
19
|
this.animating = false;
|
|
15
20
|
this.velocity = 0;
|
|
@@ -17,30 +22,58 @@ class TouchScrollManager {
|
|
|
17
22
|
this.lastTime = 0;
|
|
18
23
|
this.accumulatedScroll = 0;
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
// Activate on any touch-capable device (not just coarse pointer)
|
|
26
|
+
// Some tablets/devices report 'fine' pointer but still support touch
|
|
27
|
+
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches ||
|
|
28
|
+
'ontouchstart' in window ||
|
|
29
|
+
navigator.maxTouchPoints > 0;
|
|
30
|
+
|
|
31
|
+
if (isTouchDevice) {
|
|
32
|
+
this.bindEvents();
|
|
33
|
+
}
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
bindEvents() {
|
|
37
|
+
// Create an invisible overlay to capture touch events
|
|
38
|
+
// Uses CSS class .touch-scroll-overlay for z-index: 9999
|
|
39
|
+
// See styles.css for full documentation on why z-index must stay high
|
|
25
40
|
this.overlay = document.createElement('div');
|
|
26
41
|
this.overlay.className = 'touch-scroll-overlay';
|
|
42
|
+
this.overlay.dataset.purpose = 'touch-scroll'; // For debugging in DevTools
|
|
27
43
|
this.container.style.position = 'relative';
|
|
44
|
+
this.container.classList.add('touch-scroll-active'); // Enable touch-specific CSS
|
|
28
45
|
this.container.appendChild(this.overlay);
|
|
29
46
|
|
|
30
47
|
this.overlay.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: false });
|
|
31
48
|
this.overlay.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false });
|
|
32
49
|
this.overlay.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
|
|
33
|
-
this.overlay.addEventListener('
|
|
50
|
+
this.overlay.addEventListener('touchcancel', () => {
|
|
51
|
+
this.tracking = false;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Fallback: if the browser synthesizes a click from a tap, use it to focus the terminal.
|
|
55
|
+
// This catches cases where touchend didn't trigger the keyboard (e.g. some Android browsers).
|
|
56
|
+
this.overlay.addEventListener('click', (e) => {
|
|
57
|
+
const elem = document.elementFromPoint(e.clientX, e.clientY);
|
|
58
|
+
if (elem && elem.closest('button, a, input, select, textarea, [role="button"]')) {
|
|
59
|
+
return; // Let interactive elements handle their own clicks
|
|
60
|
+
}
|
|
61
|
+
this.terminal.focus();
|
|
62
|
+
});
|
|
34
63
|
}
|
|
35
64
|
|
|
36
65
|
onTouchStart(e) {
|
|
66
|
+
// Stop any ongoing momentum animation
|
|
37
67
|
this.animating = false;
|
|
68
|
+
|
|
38
69
|
if (e.touches.length !== 1) return;
|
|
70
|
+
|
|
39
71
|
this.tracking = true;
|
|
40
72
|
this.velocity = 0;
|
|
41
73
|
this.accumulatedScroll = 0;
|
|
42
|
-
this.totalMovement = 0;
|
|
74
|
+
this.totalMovement = 0; // Track total movement to detect taps
|
|
43
75
|
this.startY = e.touches[0].clientY;
|
|
76
|
+
this.startX = e.touches[0].clientX;
|
|
44
77
|
this.lastY = this.startY;
|
|
45
78
|
this.lastTime = Date.now();
|
|
46
79
|
this.startTime = this.lastTime;
|
|
@@ -48,453 +81,2248 @@ class TouchScrollManager {
|
|
|
48
81
|
|
|
49
82
|
onTouchMove(e) {
|
|
50
83
|
if (!this.tracking || e.touches.length !== 1) return;
|
|
84
|
+
|
|
51
85
|
const currentY = e.touches[0].clientY;
|
|
52
86
|
const currentTime = Date.now();
|
|
53
87
|
const deltaY = this.lastY - currentY;
|
|
54
88
|
const deltaTime = currentTime - this.lastTime;
|
|
89
|
+
|
|
90
|
+
// Track total movement to distinguish taps from swipes
|
|
55
91
|
this.totalMovement += Math.abs(deltaY);
|
|
56
|
-
|
|
92
|
+
|
|
93
|
+
// Only prevent default (page bounce) once we've confirmed this is a scroll gesture.
|
|
94
|
+
// Calling preventDefault() too early breaks the user activation chain on iOS/Android,
|
|
95
|
+
// which prevents the virtual keyboard from appearing on terminal.focus().
|
|
96
|
+
if (this.totalMovement >= this.TAP_THRESHOLD) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
}
|
|
99
|
+
|
|
57
100
|
if (deltaTime > 0) {
|
|
58
|
-
|
|
101
|
+
// Exponential smoothing for velocity
|
|
102
|
+
const instantVelocity = (deltaY / deltaTime) * 1000; // px/s
|
|
59
103
|
this.velocity = 0.8 * instantVelocity + 0.2 * this.velocity;
|
|
60
104
|
}
|
|
105
|
+
|
|
106
|
+
// Accumulate scroll and apply when we have enough for a line
|
|
61
107
|
this.accumulatedScroll += deltaY;
|
|
62
108
|
const linesToScroll = Math.trunc(this.accumulatedScroll / this.LINE_HEIGHT);
|
|
109
|
+
|
|
63
110
|
if (linesToScroll !== 0) {
|
|
64
111
|
this.terminal.scrollLines(linesToScroll);
|
|
65
112
|
this.accumulatedScroll -= linesToScroll * this.LINE_HEIGHT;
|
|
66
113
|
}
|
|
114
|
+
|
|
67
115
|
this.lastY = currentY;
|
|
68
116
|
this.lastTime = currentTime;
|
|
69
117
|
}
|
|
70
118
|
|
|
71
|
-
onTouchEnd() {
|
|
119
|
+
onTouchEnd(e) {
|
|
72
120
|
if (!this.tracking) return;
|
|
73
121
|
this.tracking = false;
|
|
122
|
+
|
|
74
123
|
const duration = Date.now() - this.startTime;
|
|
124
|
+
|
|
125
|
+
// If minimal movement and short duration, treat as tap and focus terminal
|
|
75
126
|
if (this.totalMovement < this.TAP_THRESHOLD && duration < this.TAP_DURATION) {
|
|
127
|
+
// Hide overlay briefly to find what's under the tap
|
|
128
|
+
this.overlay.style.pointerEvents = 'none';
|
|
129
|
+
const elem = document.elementFromPoint(this.startX, this.startY);
|
|
130
|
+
this.overlay.style.pointerEvents = '';
|
|
131
|
+
|
|
132
|
+
// If tapped element is an interactive control (button, link, input), activate it
|
|
133
|
+
if (elem && elem.closest('button, a, input, select, textarea, [role="button"]')) {
|
|
134
|
+
elem.closest('button, a, input, select, textarea, [role="button"]').click();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Otherwise focus the terminal via xterm API so the mobile keyboard appears.
|
|
139
|
+
// IMPORTANT: terminal.focus() must be called synchronously within the touch
|
|
140
|
+
// event handler for iOS/Android to show the virtual keyboard.
|
|
76
141
|
this.terminal.focus();
|
|
77
142
|
return;
|
|
78
143
|
}
|
|
79
|
-
|
|
144
|
+
|
|
145
|
+
// Start momentum animation if velocity exceeds threshold
|
|
146
|
+
if (Math.abs(this.velocity) > this.VELOCITY_THRESHOLD) {
|
|
147
|
+
this.startMomentum();
|
|
148
|
+
}
|
|
80
149
|
}
|
|
81
150
|
|
|
82
151
|
startMomentum() {
|
|
83
152
|
this.animating = true;
|
|
84
153
|
const startTime = Date.now();
|
|
85
154
|
const startVelocity = this.velocity;
|
|
155
|
+
let accumulatedDistance = 0;
|
|
86
156
|
let scrolledLines = 0;
|
|
157
|
+
|
|
87
158
|
const animate = () => {
|
|
88
159
|
if (!this.animating) return;
|
|
160
|
+
|
|
89
161
|
const elapsed = Date.now() - startTime;
|
|
162
|
+
// Exponential decay: v(t) = v0 * e^(-t/τ)
|
|
90
163
|
const currentVelocity = startVelocity * Math.exp(-elapsed / this.TIME_CONSTANT);
|
|
164
|
+
|
|
165
|
+
// Stop when velocity is too low
|
|
91
166
|
if (Math.abs(currentVelocity) < this.VELOCITY_THRESHOLD) {
|
|
92
167
|
this.animating = false;
|
|
93
168
|
return;
|
|
94
169
|
}
|
|
170
|
+
|
|
171
|
+
// Calculate total distance scrolled using integral of velocity
|
|
172
|
+
// ∫v0*e^(-t/τ)dt = -v0*τ*e^(-t/τ) + v0*τ
|
|
95
173
|
const totalDistance = startVelocity * this.TIME_CONSTANT * (1 - Math.exp(-elapsed / this.TIME_CONSTANT)) / 1000;
|
|
174
|
+
|
|
175
|
+
// Convert to lines and scroll the delta
|
|
96
176
|
const totalLines = Math.trunc(totalDistance / this.LINE_HEIGHT);
|
|
97
177
|
const linesToScroll = totalLines - scrolledLines;
|
|
178
|
+
|
|
98
179
|
if (linesToScroll !== 0) {
|
|
99
180
|
this.terminal.scrollLines(linesToScroll);
|
|
100
181
|
scrolledLines = totalLines;
|
|
101
182
|
}
|
|
183
|
+
|
|
102
184
|
requestAnimationFrame(animate);
|
|
103
185
|
};
|
|
186
|
+
|
|
104
187
|
requestAnimationFrame(animate);
|
|
105
188
|
}
|
|
106
189
|
}
|
|
107
190
|
|
|
108
|
-
|
|
191
|
+
// Notification Manager for input-required alerts
|
|
192
|
+
class NotificationManager {
|
|
193
|
+
constructor(app) {
|
|
194
|
+
this.app = app;
|
|
195
|
+
this.enabled = false;
|
|
196
|
+
this.registration = null;
|
|
197
|
+
this.lastNotificationTime = new Map();
|
|
198
|
+
this.debounceTimers = new Map();
|
|
199
|
+
this.DEBOUNCE_MS = 500;
|
|
200
|
+
this.COOLDOWN_MS = 5000;
|
|
201
|
+
|
|
202
|
+
this.loadSettings();
|
|
203
|
+
this.registerServiceWorker();
|
|
204
|
+
this.listenForServiceWorkerMessages();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async registerServiceWorker() {
|
|
208
|
+
if ('serviceWorker' in navigator) {
|
|
209
|
+
try {
|
|
210
|
+
this.registration = await navigator.serviceWorker.register('/sw.js');
|
|
211
|
+
console.log('Service worker registered');
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.warn('Service worker registration failed:', error);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
listenForServiceWorkerMessages() {
|
|
219
|
+
navigator.serviceWorker?.addEventListener('message', (event) => {
|
|
220
|
+
if (event.data.type === 'switch-session') {
|
|
221
|
+
this.app.attachSession(event.data.sessionId);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
loadSettings() {
|
|
227
|
+
try {
|
|
228
|
+
const settings = JSON.parse(localStorage.getItem('ccr-settings') || '{}');
|
|
229
|
+
this.enabled = settings.notificationsEnabled || false;
|
|
230
|
+
} catch {
|
|
231
|
+
this.enabled = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
saveSettings() {
|
|
236
|
+
localStorage.setItem('ccr-settings', JSON.stringify({
|
|
237
|
+
notificationsEnabled: this.enabled
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Apply preferences received from server (persists across different URLs)
|
|
242
|
+
applyFromServer(preferences) {
|
|
243
|
+
if (preferences && typeof preferences.notificationsEnabled === 'boolean') {
|
|
244
|
+
this.enabled = preferences.notificationsEnabled;
|
|
245
|
+
this.saveSettings(); // Also save locally as cache
|
|
246
|
+
|
|
247
|
+
// Auto-prompt for permission if server says enabled but browser hasn't granted yet
|
|
248
|
+
// This handles the case of new tunnel URLs where permission needs re-granting
|
|
249
|
+
if (this.enabled && Notification.permission === 'default') {
|
|
250
|
+
this.requestPermission().then(granted => {
|
|
251
|
+
if (!granted) {
|
|
252
|
+
// User denied on this origin - disable to avoid repeated prompts
|
|
253
|
+
this.enabled = false;
|
|
254
|
+
this.saveSettings();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Sync preferences to server
|
|
262
|
+
syncToServer() {
|
|
263
|
+
if (this.app?.ws?.readyState === WebSocket.OPEN) {
|
|
264
|
+
this.app.sendControl({
|
|
265
|
+
type: 'preferences:set',
|
|
266
|
+
preferences: { notificationsEnabled: this.enabled }
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async requestPermission() {
|
|
272
|
+
if (!('Notification' in window)) return false;
|
|
273
|
+
if (Notification.permission === 'granted') return true;
|
|
274
|
+
if (Notification.permission === 'denied') return false;
|
|
275
|
+
|
|
276
|
+
const result = await Notification.requestPermission();
|
|
277
|
+
return result === 'granted';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async enable() {
|
|
281
|
+
const granted = await this.requestPermission();
|
|
282
|
+
if (granted) {
|
|
283
|
+
this.enabled = true;
|
|
284
|
+
this.saveSettings();
|
|
285
|
+
this.syncToServer();
|
|
286
|
+
}
|
|
287
|
+
return granted;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
disable() {
|
|
291
|
+
this.enabled = false;
|
|
292
|
+
this.saveSettings();
|
|
293
|
+
this.syncToServer();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
isActiveSession(sessionId) {
|
|
297
|
+
return this.app.currentSessionId === sessionId;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
shouldNotify(sessionId) {
|
|
301
|
+
if (!this.enabled) return false;
|
|
302
|
+
if (Notification.permission !== 'granted') return false;
|
|
303
|
+
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
const lastTime = this.lastNotificationTime.get(sessionId) || 0;
|
|
306
|
+
|
|
307
|
+
// Cooldown check
|
|
308
|
+
if (now - lastTime < this.COOLDOWN_MS) return false;
|
|
309
|
+
|
|
310
|
+
// Active session + focused check
|
|
311
|
+
if (this.isActiveSession(sessionId) && document.hasFocus()) return false;
|
|
312
|
+
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
notify(sessionId, sessionName, preview) {
|
|
317
|
+
// Cancel existing debounce timer
|
|
318
|
+
clearTimeout(this.debounceTimers.get(sessionId));
|
|
319
|
+
|
|
320
|
+
// Set new debounce timer
|
|
321
|
+
this.debounceTimers.set(sessionId, setTimeout(() => {
|
|
322
|
+
if (this.shouldNotify(sessionId)) {
|
|
323
|
+
this.showNotification(sessionId, sessionName, preview);
|
|
324
|
+
this.lastNotificationTime.set(sessionId, Date.now());
|
|
325
|
+
}
|
|
326
|
+
}, this.DEBOUNCE_MS));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
showNotification(sessionId, sessionName, preview) {
|
|
330
|
+
const title = `Input needed: ${sessionName}`;
|
|
331
|
+
const body = preview.length > 100 ? preview.slice(0, 100) + '...' : preview;
|
|
332
|
+
|
|
333
|
+
if (this.registration?.active) {
|
|
334
|
+
this.registration.active.postMessage({
|
|
335
|
+
type: 'show-notification',
|
|
336
|
+
title,
|
|
337
|
+
body,
|
|
338
|
+
sessionId,
|
|
339
|
+
tag: `input-${sessionId}`
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Activity status spinner frames (same as claude-glasses)
|
|
346
|
+
const SPINNER_FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
347
|
+
|
|
348
|
+
class ClaudeRemote {
|
|
109
349
|
constructor() {
|
|
110
350
|
this.ws = null;
|
|
111
351
|
this.terminal = null;
|
|
112
352
|
this.fitAddon = null;
|
|
113
353
|
this.serializeAddon = null;
|
|
114
|
-
this.reconnectTimer = null;
|
|
115
|
-
this.ctrlActive = false;
|
|
116
|
-
this.shiftActive = false;
|
|
117
|
-
|
|
118
|
-
this.sessions = [];
|
|
119
354
|
this.currentSessionId = null;
|
|
120
|
-
this.
|
|
355
|
+
this.sessions = [];
|
|
356
|
+
this.externalSessions = [];
|
|
357
|
+
this.sessionCache = new Map(); // Cache terminal content per session for instant switching
|
|
358
|
+
this.reconnectInterval = null;
|
|
359
|
+
this.spinnerFrame = 0;
|
|
360
|
+
|
|
361
|
+
// Schedules state
|
|
362
|
+
this.schedules = [];
|
|
363
|
+
this.schedulesBadgeCount = 0;
|
|
364
|
+
this.schedulesLastViewed = 0;
|
|
365
|
+
this.expandedScheduleRuns = new Map(); // scheduleId -> RunLog[]
|
|
366
|
+
|
|
367
|
+
// Check URL for token first, then localStorage
|
|
368
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
369
|
+
const urlToken = urlParams.get('token');
|
|
370
|
+
this.token = urlToken || localStorage.getItem('authToken') || '';
|
|
371
|
+
|
|
372
|
+
// Clean token from URL for security
|
|
373
|
+
if (urlToken) {
|
|
374
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
375
|
+
}
|
|
121
376
|
|
|
122
377
|
this.initElements();
|
|
123
|
-
this.initTerminal();
|
|
124
378
|
this.bindEvents();
|
|
125
|
-
this.
|
|
379
|
+
this.initTerminal();
|
|
380
|
+
|
|
381
|
+
// Initialize notification manager
|
|
382
|
+
this.notificationManager = new NotificationManager(this);
|
|
383
|
+
this.updateNotifyToggleState();
|
|
384
|
+
|
|
385
|
+
// Start spinner animation for busy indicators (100ms = smooth animation)
|
|
386
|
+
setInterval(() => {
|
|
387
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
388
|
+
this.updateSpinnerFrames();
|
|
389
|
+
}, 100);
|
|
390
|
+
|
|
391
|
+
// Auto-connect if we have a token
|
|
392
|
+
if (urlToken) {
|
|
393
|
+
this.elements.tokenInput.value = this.token;
|
|
394
|
+
setTimeout(() => this.connect(), 100);
|
|
395
|
+
} else if (this.token) {
|
|
396
|
+
this.elements.tokenInput.value = this.token;
|
|
397
|
+
}
|
|
126
398
|
}
|
|
127
399
|
|
|
128
400
|
initElements() {
|
|
129
401
|
this.elements = {
|
|
402
|
+
// Screens
|
|
403
|
+
authScreen: document.getElementById('auth-screen'),
|
|
404
|
+
mainScreen: document.getElementById('main-screen'),
|
|
405
|
+
previewScreen: document.getElementById('preview-screen'),
|
|
406
|
+
|
|
407
|
+
// Auth
|
|
408
|
+
tokenInput: document.getElementById('token-input'),
|
|
409
|
+
connectBtn: document.getElementById('connect-btn'),
|
|
410
|
+
authError: document.getElementById('auth-error'),
|
|
411
|
+
|
|
412
|
+
// Main
|
|
130
413
|
header: document.getElementById('header'),
|
|
131
|
-
terminalContainer: document.getElementById('terminal-container'),
|
|
132
414
|
sessionSelect: document.getElementById('session-select'),
|
|
133
415
|
sessionTabs: document.getElementById('session-tabs'),
|
|
134
416
|
newSessionBtn: document.getElementById('new-session-btn'),
|
|
135
417
|
closeSessionBtn: document.getElementById('close-session-btn'),
|
|
418
|
+
previewBtn: document.getElementById('preview-btn'),
|
|
136
419
|
attachBtn: document.getElementById('attach-btn'),
|
|
137
420
|
imageInput: document.getElementById('image-input'),
|
|
138
|
-
previewBtn: document.getElementById('preview-btn'),
|
|
139
|
-
settingsBtn: document.getElementById('settings-btn'),
|
|
140
421
|
toggleHeaderBtn: document.getElementById('toggle-header-btn'),
|
|
141
422
|
toggleHeaderBtnDesktop: document.getElementById('toggle-header-btn-desktop'),
|
|
142
423
|
expandHeaderBtn: document.getElementById('expand-header-btn'),
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
424
|
+
terminalContainer: document.getElementById('terminal-container'),
|
|
425
|
+
|
|
426
|
+
// Preview
|
|
427
|
+
backBtn: document.getElementById('back-btn'),
|
|
428
|
+
portSelect: document.getElementById('port-select'),
|
|
429
|
+
portInput: document.getElementById('port-input'),
|
|
430
|
+
goPortBtn: document.getElementById('go-port-btn'),
|
|
431
|
+
refreshPreviewBtn: document.getElementById('refresh-preview-btn'),
|
|
432
|
+
previewFrame: document.getElementById('preview-frame'),
|
|
433
|
+
addressInput: document.getElementById('address-input'),
|
|
434
|
+
addressGoBtn: document.getElementById('address-go-btn'),
|
|
435
|
+
|
|
436
|
+
// Modal
|
|
147
437
|
newSessionModal: document.getElementById('new-session-modal'),
|
|
148
438
|
cwdInput: document.getElementById('cwd-input'),
|
|
149
439
|
cwdSuggestions: document.getElementById('cwd-suggestions'),
|
|
150
|
-
createSessionBtn: document.getElementById('create-session-btn'),
|
|
151
440
|
cancelSessionBtn: document.getElementById('cancel-session-btn'),
|
|
441
|
+
createSessionBtn: document.getElementById('create-session-btn'),
|
|
442
|
+
|
|
443
|
+
// Reconnect
|
|
444
|
+
reconnectIndicator: document.getElementById('reconnect-indicator'),
|
|
445
|
+
|
|
446
|
+
// Mobile keys
|
|
447
|
+
mobileKeys: document.getElementById('mobile-keys'),
|
|
448
|
+
|
|
449
|
+
// Scroll to bottom
|
|
450
|
+
scrollBottomBtn: document.getElementById('scroll-bottom-btn'),
|
|
451
|
+
|
|
452
|
+
// Settings
|
|
453
|
+
settingsBtn: document.getElementById('settings-btn'),
|
|
152
454
|
settingsModal: document.getElementById('settings-modal'),
|
|
153
|
-
closeSettingsBtn: document.getElementById('close-settings-btn')
|
|
455
|
+
closeSettingsBtn: document.getElementById('close-settings-btn'),
|
|
456
|
+
notifyToggle: document.getElementById('notify-toggle'),
|
|
457
|
+
|
|
458
|
+
// Schedules
|
|
459
|
+
schedulesScreen: document.getElementById('schedules-screen'),
|
|
460
|
+
schedulesBackBtn: document.getElementById('schedules-back-btn'),
|
|
461
|
+
schedulesHeaderBadge: document.getElementById('schedules-header-badge'),
|
|
462
|
+
schedulesList: document.getElementById('schedules-list'),
|
|
463
|
+
schedulesEmpty: document.getElementById('schedules-empty'),
|
|
464
|
+
newScheduleBtn: document.getElementById('new-schedule-btn'),
|
|
465
|
+
// New schedule modal
|
|
466
|
+
newScheduleModal: document.getElementById('new-schedule-modal'),
|
|
467
|
+
scheduleNameInput: document.getElementById('schedule-name-input'),
|
|
468
|
+
schedulePromptInput: document.getElementById('schedule-prompt-input'),
|
|
469
|
+
scheduleCwdInput: document.getElementById('schedule-cwd-input'),
|
|
470
|
+
scheduleCwdSuggestions: document.getElementById('schedule-cwd-suggestions'),
|
|
471
|
+
scheduleTextInput: document.getElementById('schedule-text-input'),
|
|
472
|
+
cancelScheduleBtn: document.getElementById('cancel-schedule-btn'),
|
|
473
|
+
createScheduleBtn: document.getElementById('create-schedule-btn'),
|
|
474
|
+
// Run log modal
|
|
475
|
+
runLogModal: document.getElementById('run-log-modal'),
|
|
476
|
+
runLogTitle: document.getElementById('run-log-title'),
|
|
477
|
+
runLogPre: document.getElementById('run-log-pre'),
|
|
478
|
+
closeRunLogBtn: document.getElementById('close-run-log-btn'),
|
|
154
479
|
};
|
|
480
|
+
|
|
481
|
+
// Mobile keys state
|
|
482
|
+
this.ctrlActive = false;
|
|
483
|
+
this.shiftActive = false;
|
|
484
|
+
this.lastViewportHeight = window.visualViewport?.height || window.innerHeight;
|
|
485
|
+
|
|
486
|
+
// Autocomplete state
|
|
487
|
+
this.selectedSuggestionIndex = -1;
|
|
488
|
+
this.suggestions = [];
|
|
489
|
+
this.debounceTimer = null;
|
|
490
|
+
|
|
491
|
+
// Schedule autocomplete state (separate from session cwd)
|
|
492
|
+
this.scheduleSelectedIndex = -1;
|
|
493
|
+
this.scheduleSuggestions = [];
|
|
494
|
+
this.scheduleDebounceTimer = null;
|
|
155
495
|
}
|
|
156
496
|
|
|
157
497
|
initTerminal() {
|
|
498
|
+
// Create terminal with mobile-friendly settings
|
|
158
499
|
this.terminal = new Terminal({
|
|
159
500
|
cursorBlink: true,
|
|
501
|
+
cursorStyle: 'bar',
|
|
502
|
+
cursorWidth: 2,
|
|
160
503
|
fontSize: 14,
|
|
161
|
-
fontFamily: '"JetBrains Mono", Menlo, Monaco, monospace',
|
|
504
|
+
fontFamily: '"JetBrains Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"',
|
|
505
|
+
fontWeight: '400',
|
|
506
|
+
fontWeightBold: '600',
|
|
507
|
+
letterSpacing: 0,
|
|
508
|
+
lineHeight: 1.2,
|
|
162
509
|
theme: {
|
|
163
510
|
background: '#0d1117',
|
|
164
511
|
foreground: '#f0f6fc',
|
|
165
|
-
cursor: '#
|
|
512
|
+
cursor: '#f0a500',
|
|
513
|
+
cursorAccent: '#0d1117',
|
|
514
|
+
selectionBackground: 'rgba(240, 165, 0, 0.25)',
|
|
515
|
+
selectionForeground: '#f0f6fc',
|
|
516
|
+
black: '#484f58',
|
|
517
|
+
red: '#ff7b72',
|
|
518
|
+
green: '#3fb950',
|
|
519
|
+
yellow: '#d29922',
|
|
520
|
+
blue: '#58a6ff',
|
|
521
|
+
magenta: '#bc8cff',
|
|
522
|
+
cyan: '#39c5cf',
|
|
523
|
+
white: '#b1bac4',
|
|
524
|
+
brightBlack: '#6e7681',
|
|
525
|
+
brightRed: '#ffa198',
|
|
526
|
+
brightGreen: '#56d364',
|
|
527
|
+
brightYellow: '#e3b341',
|
|
528
|
+
brightBlue: '#79c0ff',
|
|
529
|
+
brightMagenta: '#d2a8ff',
|
|
530
|
+
brightCyan: '#56d4dd',
|
|
531
|
+
brightWhite: '#f0f6fc',
|
|
166
532
|
},
|
|
167
|
-
|
|
533
|
+
scrollback: 5000,
|
|
534
|
+
allowTransparency: true,
|
|
535
|
+
convertEol: true,
|
|
168
536
|
});
|
|
169
537
|
|
|
538
|
+
// Add fit addon for responsive sizing
|
|
170
539
|
this.fitAddon = new FitAddon.FitAddon();
|
|
171
540
|
this.terminal.loadAddon(this.fitAddon);
|
|
172
|
-
|
|
173
|
-
|
|
541
|
+
|
|
542
|
+
// Add web links addon for clickable URLs
|
|
543
|
+
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
|
544
|
+
this.terminal.loadAddon(webLinksAddon);
|
|
545
|
+
|
|
546
|
+
// Add serialize addon for instant tab switching
|
|
174
547
|
this.serializeAddon = new SerializeAddon.SerializeAddon();
|
|
175
548
|
this.terminal.loadAddon(this.serializeAddon);
|
|
176
549
|
|
|
550
|
+
// Open terminal in container
|
|
177
551
|
this.terminal.open(this.elements.terminalContainer);
|
|
178
|
-
this.fitAddon.fit();
|
|
179
552
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
553
|
+
// Handle macOS keyboard shortcuts (Cmd+Backspace, Cmd+Left, etc.)
|
|
554
|
+
this.terminal.attachCustomKeyEventHandler((e) => {
|
|
555
|
+
// Only handle keydown events to prevent double-firing
|
|
556
|
+
if (e.type !== 'keydown') {
|
|
557
|
+
return true;
|
|
184
558
|
}
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
this.terminal.onResize(({ cols, rows }) => {
|
|
188
|
-
this.sendControl({ type: 'resize', cols, rows });
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
window.addEventListener('resize', () => {
|
|
192
|
-
this.fitAddon.fit();
|
|
193
|
-
});
|
|
194
559
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
this.elements.scrollBottomBtn.classList.toggle('hidden', isAtBottom);
|
|
199
|
-
});
|
|
560
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.currentSessionId) {
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
200
563
|
|
|
201
|
-
|
|
202
|
-
|
|
564
|
+
const isMac = navigator.platform.includes('Mac');
|
|
565
|
+
const cmdKey = isMac ? e.metaKey : e.ctrlKey;
|
|
566
|
+
const optKey = e.altKey;
|
|
203
567
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
this.ws = new WebSocket(`${protocol}//${host}${token ? '?token=' + token : ''}`);
|
|
568
|
+
// Handle Escape key explicitly (xterm.js doesn't always pass it through)
|
|
569
|
+
if (e.key === 'Escape') {
|
|
570
|
+
e.preventDefault();
|
|
571
|
+
this.ws.send('\x1b');
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
211
574
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
this.reconnectTimer = null;
|
|
575
|
+
// Handle Shift+Enter to insert newline instead of sending
|
|
576
|
+
if (e.key === 'Enter' && e.shiftKey && !cmdKey && !optKey) {
|
|
577
|
+
e.preventDefault();
|
|
578
|
+
this.ws.send('\n');
|
|
579
|
+
return false;
|
|
218
580
|
}
|
|
219
|
-
this.refreshSessionList();
|
|
220
|
-
};
|
|
221
581
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
582
|
+
if (cmdKey && !e.shiftKey) {
|
|
583
|
+
switch (e.key) {
|
|
584
|
+
case 'Backspace':
|
|
585
|
+
// Cmd+Backspace: Clear line to left (Ctrl+U)
|
|
586
|
+
e.preventDefault();
|
|
587
|
+
this.ws.send('\x15');
|
|
588
|
+
return false;
|
|
589
|
+
case 'ArrowLeft':
|
|
590
|
+
// Cmd+Left: Beginning of line (Ctrl+A)
|
|
591
|
+
e.preventDefault();
|
|
592
|
+
this.ws.send('\x01');
|
|
593
|
+
return false;
|
|
594
|
+
case 'ArrowRight':
|
|
595
|
+
// Cmd+Right: End of line (Ctrl+E)
|
|
596
|
+
e.preventDefault();
|
|
597
|
+
this.ws.send('\x05');
|
|
598
|
+
return false;
|
|
599
|
+
case 'k':
|
|
600
|
+
// Cmd+K: Clear terminal
|
|
601
|
+
e.preventDefault();
|
|
602
|
+
this.terminal.clear();
|
|
603
|
+
return false;
|
|
231
604
|
}
|
|
232
|
-
} else {
|
|
233
|
-
// Text message = raw terminal output
|
|
234
|
-
this.terminal.write(event.data);
|
|
235
605
|
}
|
|
236
|
-
};
|
|
237
606
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
607
|
+
if (optKey && !cmdKey && !e.shiftKey) {
|
|
608
|
+
switch (e.key) {
|
|
609
|
+
case 'Backspace':
|
|
610
|
+
// Option+Backspace: Delete word (Ctrl+W)
|
|
611
|
+
e.preventDefault();
|
|
612
|
+
this.ws.send('\x17');
|
|
613
|
+
return false;
|
|
614
|
+
case 'ArrowLeft':
|
|
615
|
+
// Option+Left: Move word left (ESC+b)
|
|
616
|
+
e.preventDefault();
|
|
617
|
+
this.ws.send('\x1bb');
|
|
618
|
+
return false;
|
|
619
|
+
case 'ArrowRight':
|
|
620
|
+
// Option+Right: Move word right (ESC+f)
|
|
621
|
+
e.preventDefault();
|
|
622
|
+
this.ws.send('\x1bf');
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
243
625
|
}
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
626
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
case 'session:attached':
|
|
250
|
-
this.currentSessionId = message.session.id;
|
|
251
|
-
this.updateUI();
|
|
252
|
-
break;
|
|
253
|
-
case 'session:created':
|
|
254
|
-
this.refreshSessionList();
|
|
255
|
-
this.hideNewSessionModal();
|
|
256
|
-
break;
|
|
257
|
-
case 'session:list':
|
|
258
|
-
this.sessions = message.sessions;
|
|
259
|
-
this.renderSessions();
|
|
260
|
-
break;
|
|
261
|
-
case 'image:uploaded':
|
|
262
|
-
// Paste the path into terminal (usually for /attach command)
|
|
263
|
-
this.terminal.focus();
|
|
264
|
-
this.terminal.paste(message.path);
|
|
265
|
-
break;
|
|
266
|
-
case 'session:destroyed':
|
|
267
|
-
if (message.sessionId === this.currentSessionId) {
|
|
268
|
-
this.currentSessionId = null;
|
|
269
|
-
this.terminal.clear();
|
|
270
|
-
}
|
|
271
|
-
this.refreshSessionList();
|
|
272
|
-
break;
|
|
273
|
-
case 'error':
|
|
274
|
-
this.terminal.writeln(`\r\n\x1b[31mError: ${message.error}\x1b[0m`);
|
|
275
|
-
break;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
627
|
+
return true;
|
|
628
|
+
});
|
|
278
629
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
this.ws.
|
|
282
|
-
|
|
283
|
-
|
|
630
|
+
// Handle terminal input -> send to server
|
|
631
|
+
this.terminal.onData((data) => {
|
|
632
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentSessionId) {
|
|
633
|
+
// Apply modifiers if active
|
|
634
|
+
if (data.length === 1) {
|
|
635
|
+
const char = data.charCodeAt(0);
|
|
284
636
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
637
|
+
// Apply Shift modifier (convert lowercase to uppercase)
|
|
638
|
+
if (this.shiftActive && char >= 97 && char <= 122) {
|
|
639
|
+
data = String.fromCharCode(char - 32);
|
|
640
|
+
this.setShiftActive(false);
|
|
641
|
+
}
|
|
288
642
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
});
|
|
643
|
+
// Apply Ctrl modifier (Ctrl+A = 0x01, Ctrl+Z = 0x1A)
|
|
644
|
+
if (this.ctrlActive) {
|
|
645
|
+
if (char >= 65 && char <= 90) { // A-Z
|
|
646
|
+
data = String.fromCharCode(char - 64);
|
|
647
|
+
} else if (char >= 97 && char <= 122) { // a-z
|
|
648
|
+
data = String.fromCharCode(char - 96);
|
|
649
|
+
}
|
|
650
|
+
this.setCtrlActive(false);
|
|
651
|
+
}
|
|
299
652
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
name.className = 'session-tab-name';
|
|
309
|
-
name.textContent = s.cwd.split('/').pop() || '/';
|
|
310
|
-
tab.appendChild(name);
|
|
311
|
-
|
|
312
|
-
const close = document.createElement('span');
|
|
313
|
-
close.className = 'session-tab-close';
|
|
314
|
-
close.innerHTML = '×';
|
|
315
|
-
close.onclick = (e) => {
|
|
316
|
-
e.stopPropagation();
|
|
317
|
-
this.destroySession(s.id);
|
|
318
|
-
};
|
|
319
|
-
tab.appendChild(close);
|
|
653
|
+
// Auto-dismiss keyboard on Enter (mobile only) - delay to avoid race conditions
|
|
654
|
+
if ((char === 13 || char === 10) && this.elements.mobileKeys.classList.contains('visible')) {
|
|
655
|
+
setTimeout(() => this.terminal.blur(), 50);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
this.ws.send(data); // Send as text
|
|
659
|
+
}
|
|
660
|
+
});
|
|
320
661
|
|
|
321
|
-
|
|
322
|
-
|
|
662
|
+
// Handle resize -> notify server
|
|
663
|
+
this.terminal.onResize(({ cols, rows }) => {
|
|
664
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentSessionId) {
|
|
665
|
+
this.sendControl({ type: 'resize', cols, rows });
|
|
666
|
+
}
|
|
323
667
|
});
|
|
324
|
-
}
|
|
325
668
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
this.sendControl({ type: 'session:attach', sessionId: id });
|
|
329
|
-
}
|
|
669
|
+
// Fit terminal on window resize
|
|
670
|
+
window.addEventListener('resize', () => this.fitTerminal());
|
|
330
671
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
672
|
+
// Use ResizeObserver for container changes
|
|
673
|
+
const resizeObserver = new ResizeObserver(() => this.fitTerminal());
|
|
674
|
+
resizeObserver.observe(this.elements.terminalContainer);
|
|
675
|
+
|
|
676
|
+
// Track scroll position for scroll-to-bottom button
|
|
677
|
+
this.terminal.onScroll(() => this.updateScrollButton());
|
|
678
|
+
// Also check when new content is written
|
|
679
|
+
this.terminal.onWriteParsed(() => this.updateScrollButton());
|
|
680
|
+
|
|
681
|
+
// Initialize touch scroll manager for natural mobile scrolling
|
|
682
|
+
this.touchScrollManager = new TouchScrollManager(
|
|
683
|
+
this.terminal,
|
|
684
|
+
this.elements.terminalContainer
|
|
685
|
+
);
|
|
335
686
|
}
|
|
336
687
|
|
|
337
|
-
|
|
338
|
-
this.
|
|
688
|
+
fitTerminal() {
|
|
689
|
+
if (this.fitAddon && this.elements.mainScreen.classList.contains('active')) {
|
|
690
|
+
try {
|
|
691
|
+
this.fitAddon.fit();
|
|
692
|
+
} catch (e) {
|
|
693
|
+
// Ignore fit errors during transitions
|
|
694
|
+
}
|
|
695
|
+
}
|
|
339
696
|
}
|
|
340
697
|
|
|
341
698
|
bindEvents() {
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
switch (key) {
|
|
347
|
-
case 'escape': this.terminal.focus(); this.terminal.onData('\x1b'); break; // Simulated ESC
|
|
348
|
-
case 'ctrl': this.setCtrlActive(!this.ctrlActive); break;
|
|
349
|
-
case 'shift': this.setShiftActive(!this.shiftActive); break;
|
|
350
|
-
case 'tab': this.terminal.onData('\t'); break;
|
|
351
|
-
case 'up': this.terminal.onData('\x1b[A'); break;
|
|
352
|
-
case 'down': this.terminal.onData('\x1b[B'); break;
|
|
353
|
-
case 'slash': this.terminal.onData('/'); break;
|
|
354
|
-
}
|
|
355
|
-
});
|
|
699
|
+
// Auth
|
|
700
|
+
this.elements.connectBtn.addEventListener('click', () => this.connect());
|
|
701
|
+
this.elements.tokenInput.addEventListener('keydown', (e) => {
|
|
702
|
+
if (e.key === 'Enter') this.connect();
|
|
356
703
|
});
|
|
357
704
|
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
setTimeout(() => this.fitAddon.fit(), 200);
|
|
363
|
-
};
|
|
364
|
-
[this.elements.toggleHeaderBtn, this.elements.toggleHeaderBtnDesktop, this.elements.expandHeaderBtn].forEach(btn => {
|
|
365
|
-
btn?.addEventListener('click', handleToggleHeader);
|
|
366
|
-
});
|
|
705
|
+
// Main
|
|
706
|
+
this.elements.sessionSelect.addEventListener('change', (e) => {
|
|
707
|
+
const value = e.target.value;
|
|
708
|
+
if (!value) return;
|
|
367
709
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
this.
|
|
371
|
-
|
|
710
|
+
// Check if it's the schedules option
|
|
711
|
+
if (value === 'schedules') {
|
|
712
|
+
this.showSchedules();
|
|
713
|
+
// Reset dropdown
|
|
714
|
+
e.target.value = this.currentSessionId || '';
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
372
717
|
|
|
373
|
-
|
|
718
|
+
// Check if it's an external session
|
|
719
|
+
if (value.startsWith('external:')) {
|
|
720
|
+
const pid = parseInt(value.replace('external:', ''), 10);
|
|
721
|
+
const external = this.externalSessions.find(s => s.pid === pid);
|
|
722
|
+
if (external) {
|
|
723
|
+
this.adoptExternalSession(external.pid, external.cwd);
|
|
724
|
+
// Reset dropdown
|
|
725
|
+
e.target.value = this.currentSessionId || '';
|
|
726
|
+
}
|
|
727
|
+
} else {
|
|
728
|
+
this.attachSession(value);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
this.elements.newSessionBtn.addEventListener('click', () => this.showNewSessionModal());
|
|
732
|
+
this.elements.closeSessionBtn?.addEventListener('click', () => this.closeCurrentSession());
|
|
733
|
+
this.elements.previewBtn.addEventListener('click', () => this.showPreview());
|
|
374
734
|
this.elements.attachBtn.addEventListener('click', () => this.elements.imageInput.click());
|
|
375
735
|
this.elements.imageInput.addEventListener('change', (e) => {
|
|
376
|
-
|
|
377
|
-
|
|
736
|
+
if (e.target.files[0]) {
|
|
737
|
+
this.handleImageAttachment(e.target.files[0]);
|
|
738
|
+
e.target.value = ''; // Reset for same file selection
|
|
739
|
+
}
|
|
378
740
|
});
|
|
741
|
+
this.elements.toggleHeaderBtn.addEventListener('click', () => this.toggleHeader(true));
|
|
742
|
+
this.elements.toggleHeaderBtnDesktop?.addEventListener('click', () => this.toggleHeader(true));
|
|
743
|
+
this.elements.expandHeaderBtn.addEventListener('click', () => this.toggleHeader(false));
|
|
379
744
|
|
|
380
|
-
//
|
|
381
|
-
this.elements.
|
|
382
|
-
this.elements.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
this.
|
|
745
|
+
// Preview
|
|
746
|
+
this.elements.backBtn.addEventListener('click', () => this.hidePreview());
|
|
747
|
+
this.elements.portSelect.addEventListener('change', (e) => {
|
|
748
|
+
if (e.target.value) {
|
|
749
|
+
this.elements.portInput.value = e.target.value;
|
|
750
|
+
this.elements.addressInput.value = '/';
|
|
751
|
+
this.loadPreview(e.target.value, '/');
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
this.elements.goPortBtn.addEventListener('click', () => this.loadCustomPort());
|
|
755
|
+
this.elements.portInput.addEventListener('keydown', (e) => {
|
|
756
|
+
if (e.key === 'Enter') this.loadCustomPort();
|
|
757
|
+
});
|
|
758
|
+
this.elements.refreshPreviewBtn.addEventListener('click', () => this.refreshPreview());
|
|
759
|
+
this.elements.addressGoBtn.addEventListener('click', () => this.navigateToAddress());
|
|
760
|
+
this.elements.addressInput.addEventListener('keydown', (e) => {
|
|
761
|
+
if (e.key === 'Enter') this.navigateToAddress();
|
|
386
762
|
});
|
|
387
763
|
|
|
388
|
-
//
|
|
389
|
-
this.elements.
|
|
390
|
-
this.elements.
|
|
764
|
+
// Modal
|
|
765
|
+
this.elements.cancelSessionBtn.addEventListener('click', () => this.hideNewSessionModal());
|
|
766
|
+
this.elements.createSessionBtn.addEventListener('click', () => this.createSession());
|
|
391
767
|
|
|
392
|
-
//
|
|
768
|
+
// Autocomplete
|
|
393
769
|
this.elements.cwdInput.addEventListener('input', () => this.onCwdInput());
|
|
770
|
+
this.elements.cwdInput.addEventListener('keydown', (e) => this.onCwdKeydown(e));
|
|
771
|
+
this.elements.cwdInput.addEventListener('blur', () => {
|
|
772
|
+
// Delay to allow click on suggestion
|
|
773
|
+
setTimeout(() => this.hideSuggestions(), 150);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// Scroll to bottom
|
|
777
|
+
this.elements.scrollBottomBtn.addEventListener('click', () => this.scrollToBottom());
|
|
778
|
+
|
|
779
|
+
// Mobile keys
|
|
780
|
+
this.initMobileKeys();
|
|
781
|
+
|
|
782
|
+
// Settings
|
|
783
|
+
this.elements.settingsBtn.addEventListener('click', () => this.showSettings());
|
|
784
|
+
this.elements.closeSettingsBtn.addEventListener('click', () => this.hideSettings());
|
|
785
|
+
this.elements.notifyToggle.addEventListener('click', () => this.toggleNotifications());
|
|
394
786
|
|
|
395
787
|
// Paste handling for images
|
|
396
788
|
document.addEventListener('paste', (e) => this.handlePaste(e));
|
|
397
|
-
}
|
|
398
789
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
790
|
+
// Schedules
|
|
791
|
+
this.elements.schedulesBackBtn.addEventListener('click', () => this.hideSchedules());
|
|
792
|
+
this.elements.newScheduleBtn.addEventListener('click', () => this.showNewScheduleModal());
|
|
793
|
+
this.elements.cancelScheduleBtn.addEventListener('click', () => this.hideNewScheduleModal());
|
|
794
|
+
this.elements.createScheduleBtn.addEventListener('click', () => this.createSchedule());
|
|
795
|
+
this.elements.closeRunLogBtn.addEventListener('click', () => this.hideRunLogModal());
|
|
796
|
+
|
|
797
|
+
// Schedule cwd autocomplete
|
|
798
|
+
this.elements.scheduleCwdInput.addEventListener('input', () => this.onScheduleCwdInput());
|
|
799
|
+
this.elements.scheduleCwdInput.addEventListener('keydown', (e) => this.onScheduleCwdKeydown(e));
|
|
800
|
+
this.elements.scheduleCwdInput.addEventListener('blur', () => {
|
|
801
|
+
setTimeout(() => this.hideScheduleSuggestions(), 150);
|
|
802
|
+
});
|
|
412
803
|
}
|
|
413
804
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
break;
|
|
422
|
-
}
|
|
805
|
+
showScreen(screenId) {
|
|
806
|
+
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
|
807
|
+
document.getElementById(screenId).classList.add('active');
|
|
808
|
+
|
|
809
|
+
// Fit terminal when showing main screen
|
|
810
|
+
if (screenId === 'main-screen') {
|
|
811
|
+
setTimeout(() => this.fitTerminal(), 50);
|
|
423
812
|
}
|
|
424
813
|
}
|
|
425
814
|
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
815
|
+
toggleHeader(collapse) {
|
|
816
|
+
const isCollapsed = collapse !== undefined
|
|
817
|
+
? (collapse ? this.elements.header.classList.add('collapsed') || true : this.elements.header.classList.remove('collapsed') || false)
|
|
818
|
+
: this.elements.header.classList.toggle('collapsed');
|
|
819
|
+
|
|
820
|
+
const collapsed = this.elements.header.classList.contains('collapsed');
|
|
821
|
+
this.elements.toggleHeaderBtn.setAttribute('aria-expanded', !collapsed);
|
|
822
|
+
this.elements.toggleHeaderBtnDesktop?.setAttribute('aria-expanded', !collapsed);
|
|
823
|
+
this.elements.expandHeaderBtn.classList.toggle('hidden', !collapsed);
|
|
824
|
+
setTimeout(() => this.fitTerminal(), 300);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
showSettings() {
|
|
828
|
+
this.elements.settingsModal.classList.remove('hidden');
|
|
829
|
+
this.updateNotifyToggleState();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
hideSettings() {
|
|
833
|
+
this.elements.settingsModal.classList.add('hidden');
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
updateNotifyToggleState() {
|
|
837
|
+
const enabled = this.notificationManager?.enabled || false;
|
|
838
|
+
this.elements.notifyToggle.setAttribute('aria-checked', enabled);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async toggleNotifications() {
|
|
842
|
+
if (this.notificationManager.enabled) {
|
|
843
|
+
this.notificationManager.disable();
|
|
844
|
+
} else {
|
|
845
|
+
const success = await this.notificationManager.enable();
|
|
846
|
+
if (!success && Notification.permission === 'denied') {
|
|
847
|
+
alert('Notifications are blocked. Please enable them in your browser settings.');
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
this.updateNotifyToggleState();
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
connect() {
|
|
854
|
+
const token = this.elements.tokenInput.value.trim();
|
|
855
|
+
if (!token) {
|
|
856
|
+
this.elements.authError.textContent = 'Please enter a token';
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
this.token = token;
|
|
861
|
+
this.elements.authError.textContent = '';
|
|
862
|
+
this.elements.connectBtn.disabled = true;
|
|
863
|
+
this.elements.connectBtn.classList.add('loading');
|
|
864
|
+
|
|
865
|
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
866
|
+
const wsUrl = `${wsProtocol}//${window.location.host}`;
|
|
867
|
+
this.ws = new WebSocket(wsUrl);
|
|
868
|
+
|
|
869
|
+
this.ws.onopen = () => {
|
|
870
|
+
// Send auth as binary (control message)
|
|
871
|
+
this.sendControl({ type: 'auth', token });
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
this.ws.onmessage = (event) => {
|
|
875
|
+
// Check if binary (control) or text (terminal output)
|
|
876
|
+
if (event.data instanceof Blob) {
|
|
877
|
+
// Binary = control message
|
|
878
|
+
event.data.text().then(text => {
|
|
879
|
+
const message = JSON.parse(text);
|
|
880
|
+
this.handleControlMessage(message);
|
|
450
881
|
});
|
|
882
|
+
} else if (event.data instanceof ArrayBuffer) {
|
|
883
|
+
// Binary = control message
|
|
884
|
+
const text = new TextDecoder().decode(event.data);
|
|
885
|
+
const message = JSON.parse(text);
|
|
886
|
+
this.handleControlMessage(message);
|
|
887
|
+
} else {
|
|
888
|
+
// Text = terminal output -> write to xterm
|
|
889
|
+
this.terminal.write(event.data);
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
this.ws.onerror = () => {
|
|
894
|
+
this.elements.authError.textContent = 'Connection failed';
|
|
895
|
+
this.elements.connectBtn.disabled = false;
|
|
896
|
+
this.elements.connectBtn.classList.remove('loading');
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
this.ws.onclose = () => {
|
|
900
|
+
this.elements.connectBtn.disabled = false;
|
|
901
|
+
this.elements.connectBtn.classList.remove('loading');
|
|
902
|
+
if (this.elements.mainScreen.classList.contains('active')) {
|
|
903
|
+
this.startAutoReconnect();
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
startAutoReconnect() {
|
|
909
|
+
if (this.reconnectInterval) return;
|
|
910
|
+
this.elements.reconnectIndicator.classList.remove('hidden');
|
|
911
|
+
this.reconnectInterval = setInterval(() => this.attemptReconnect(), 3000);
|
|
912
|
+
// Try immediately first
|
|
913
|
+
this.attemptReconnect();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
stopAutoReconnect() {
|
|
917
|
+
if (this.reconnectInterval) {
|
|
918
|
+
clearInterval(this.reconnectInterval);
|
|
919
|
+
this.reconnectInterval = null;
|
|
920
|
+
}
|
|
921
|
+
this.elements.reconnectIndicator.classList.add('hidden');
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
attemptReconnect() {
|
|
925
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
926
|
+
this.stopAutoReconnect();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
this.connect();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
sendControl(message) {
|
|
933
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
934
|
+
// Send as binary (ArrayBuffer)
|
|
935
|
+
const data = new TextEncoder().encode(JSON.stringify(message));
|
|
936
|
+
this.ws.send(data);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
handleControlMessage(message) {
|
|
941
|
+
switch (message.type) {
|
|
942
|
+
case 'auth:success':
|
|
943
|
+
localStorage.setItem('authToken', this.token);
|
|
944
|
+
this.stopAutoReconnect();
|
|
945
|
+
this.showScreen('main-screen');
|
|
946
|
+
// Apply server-side preferences (persists across different tunnel URLs)
|
|
947
|
+
if (message.preferences) {
|
|
948
|
+
this.notificationManager.applyFromServer(message.preferences);
|
|
949
|
+
this.updateNotifyToggleState();
|
|
950
|
+
}
|
|
951
|
+
this.sendControl({ type: 'session:list' });
|
|
952
|
+
this.sendControl({ type: 'session:discover' });
|
|
953
|
+
this.sendControl({ type: 'schedule:list' });
|
|
954
|
+
this.loadPorts();
|
|
955
|
+
this.fitTerminal();
|
|
956
|
+
// Re-attach to previous session if we have one
|
|
957
|
+
if (this.currentSessionId) {
|
|
958
|
+
this.sendControl({ type: 'session:attach', sessionId: this.currentSessionId });
|
|
959
|
+
}
|
|
960
|
+
break;
|
|
961
|
+
|
|
962
|
+
case 'auth:failed':
|
|
963
|
+
this.elements.authError.textContent = message.error || 'Authentication failed';
|
|
964
|
+
this.elements.connectBtn.disabled = false;
|
|
965
|
+
this.elements.connectBtn.classList.remove('loading');
|
|
966
|
+
break;
|
|
967
|
+
|
|
968
|
+
case 'session:list':
|
|
969
|
+
this.updateSessionList(message.sessions);
|
|
970
|
+
// Auto-attach to first session if not already attached
|
|
971
|
+
if (!this.currentSessionId && message.sessions.length > 0) {
|
|
972
|
+
this.attachSession(message.sessions[0].id);
|
|
973
|
+
}
|
|
974
|
+
break;
|
|
975
|
+
|
|
976
|
+
case 'session:discovered':
|
|
977
|
+
this.updateExternalSessions(message.sessions || []);
|
|
978
|
+
break;
|
|
979
|
+
|
|
980
|
+
case 'session:created':
|
|
981
|
+
case 'session:attached': {
|
|
982
|
+
const isNewSession = message.type === 'session:created';
|
|
983
|
+
const hadCache = this.sessionCache.has(message.session.id) && this.sessionCache.get(message.session.id)?.content;
|
|
984
|
+
|
|
985
|
+
this.currentSessionId = message.session.id;
|
|
986
|
+
|
|
987
|
+
// Only show connection message for new sessions or sessions without cache
|
|
988
|
+
if (isNewSession || !hadCache) {
|
|
989
|
+
this.terminal.clear();
|
|
990
|
+
this.terminal.writeln(`\x1b[32mConnected to session: ${message.session.id}\x1b[0m`);
|
|
991
|
+
this.terminal.writeln(`\x1b[90mWorking directory: ${message.session.cwd}\x1b[0m\r\n`);
|
|
992
|
+
}
|
|
993
|
+
if (message.isAdopted) {
|
|
994
|
+
this.terminal.writeln(`\x1b[36m✓ External session adopted successfully\x1b[0m\r\n`);
|
|
995
|
+
}
|
|
996
|
+
this.sendControl({ type: 'session:list' });
|
|
997
|
+
// Refresh external sessions after adoption
|
|
998
|
+
if (message.isAdopted) {
|
|
999
|
+
this.sendControl({ type: 'session:discover' });
|
|
1000
|
+
}
|
|
1001
|
+
// Send initial size
|
|
1002
|
+
const { cols, rows } = this.terminal;
|
|
1003
|
+
this.sendControl({ type: 'resize', cols, rows });
|
|
1004
|
+
// Focus terminal
|
|
1005
|
+
this.terminal.focus();
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
case 'session:exit':
|
|
1010
|
+
this.terminal.writeln(`\r\n\x1b[33mSession exited with code ${message.exitCode}\x1b[0m`);
|
|
1011
|
+
// Auto-remove the session after it exits
|
|
1012
|
+
if (message.sessionId) {
|
|
1013
|
+
this.removeSession(message.sessionId);
|
|
1014
|
+
}
|
|
1015
|
+
break;
|
|
1016
|
+
|
|
1017
|
+
case 'session:destroyed':
|
|
1018
|
+
if (message.sessionId) {
|
|
1019
|
+
this.removeSession(message.sessionId);
|
|
1020
|
+
}
|
|
1021
|
+
break;
|
|
1022
|
+
|
|
1023
|
+
case 'image:uploaded':
|
|
1024
|
+
// Insert file path into terminal (simulating paste)
|
|
1025
|
+
if (message.path) {
|
|
1026
|
+
this.terminal.paste(message.path);
|
|
1027
|
+
}
|
|
1028
|
+
break;
|
|
1029
|
+
|
|
1030
|
+
case 'session:input_required':
|
|
1031
|
+
// Trigger notification when session needs input
|
|
1032
|
+
this.notificationManager.notify(
|
|
1033
|
+
message.sessionId,
|
|
1034
|
+
message.sessionName,
|
|
1035
|
+
message.preview
|
|
1036
|
+
);
|
|
1037
|
+
break;
|
|
1038
|
+
|
|
1039
|
+
case 'session:status':
|
|
1040
|
+
// Update activity status for sessions without full re-render
|
|
1041
|
+
this.updateSessionStatus(message.sessions, message.externalSessions);
|
|
1042
|
+
break;
|
|
1043
|
+
|
|
1044
|
+
case 'schedule:list':
|
|
1045
|
+
this.schedules = message.schedules || [];
|
|
1046
|
+
this.renderSchedules();
|
|
1047
|
+
break;
|
|
1048
|
+
|
|
1049
|
+
case 'schedule:runs':
|
|
1050
|
+
if (message.scheduleId) {
|
|
1051
|
+
this.expandedScheduleRuns.set(message.scheduleId, message.runs || []);
|
|
1052
|
+
this.renderSchedules();
|
|
1053
|
+
}
|
|
1054
|
+
break;
|
|
1055
|
+
|
|
1056
|
+
case 'schedule:log':
|
|
1057
|
+
if (message.content) {
|
|
1058
|
+
this.showRunLogModal(message.content);
|
|
1059
|
+
}
|
|
1060
|
+
break;
|
|
1061
|
+
|
|
1062
|
+
case 'schedule:run_complete':
|
|
1063
|
+
this.schedulesBadgeCount++;
|
|
1064
|
+
this.updateScheduleBadge();
|
|
1065
|
+
// Trigger push notification
|
|
1066
|
+
if (this.notificationManager?.enabled && Notification.permission === 'granted') {
|
|
1067
|
+
const status = message.exitCode === 0 ? 'completed' : 'failed';
|
|
1068
|
+
if (this.notificationManager.registration?.active) {
|
|
1069
|
+
this.notificationManager.registration.active.postMessage({
|
|
1070
|
+
type: 'show-notification',
|
|
1071
|
+
title: `Schedule ${status}: ${message.name}`,
|
|
1072
|
+
body: `Exit code: ${message.exitCode}`,
|
|
1073
|
+
sessionId: null,
|
|
1074
|
+
tag: `schedule-${message.scheduleId}`
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// Refresh schedule list to update lastRun
|
|
1079
|
+
this.sendControl({ type: 'schedule:list' });
|
|
1080
|
+
break;
|
|
1081
|
+
|
|
1082
|
+
case 'schedule:updated':
|
|
1083
|
+
this.resetScheduleCreateBtn();
|
|
1084
|
+
this.hideNewScheduleModal();
|
|
1085
|
+
// Refresh the full list
|
|
1086
|
+
this.sendControl({ type: 'schedule:list' });
|
|
1087
|
+
break;
|
|
1088
|
+
|
|
1089
|
+
case 'schedule:create_error':
|
|
1090
|
+
this.resetScheduleCreateBtn();
|
|
1091
|
+
alert(message.error || 'Failed to create schedule');
|
|
1092
|
+
break;
|
|
1093
|
+
|
|
1094
|
+
case 'error':
|
|
1095
|
+
this.terminal.writeln(`\r\n\x1b[31mError: ${message.error}\x1b[0m`);
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
handleImageAttachment(file) {
|
|
1101
|
+
if (!file || !file.type.startsWith('image/')) return;
|
|
1102
|
+
|
|
1103
|
+
const reader = new FileReader();
|
|
1104
|
+
reader.onload = () => {
|
|
1105
|
+
const base64 = reader.result.split(',')[1]; // Remove data URL prefix
|
|
1106
|
+
this.sendControl({
|
|
1107
|
+
type: 'image:upload',
|
|
1108
|
+
data: base64,
|
|
1109
|
+
filename: file.name,
|
|
1110
|
+
mimeType: file.type
|
|
1111
|
+
});
|
|
1112
|
+
};
|
|
1113
|
+
reader.readAsDataURL(file);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
handlePaste(e) {
|
|
1117
|
+
// Only handle paste when connected and on main screen
|
|
1118
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.elements.mainScreen.classList.contains('active')) {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Check if clipboard contains image data
|
|
1123
|
+
const items = e.clipboardData?.items;
|
|
1124
|
+
if (!items) return;
|
|
1125
|
+
|
|
1126
|
+
for (let i = 0; i < items.length; i++) {
|
|
1127
|
+
const item = items[i];
|
|
1128
|
+
|
|
1129
|
+
// Check if it's an image
|
|
1130
|
+
if (item.type.startsWith('image/')) {
|
|
1131
|
+
e.preventDefault();
|
|
1132
|
+
|
|
1133
|
+
const file = item.getAsFile();
|
|
1134
|
+
if (file) {
|
|
1135
|
+
// Generate a filename with timestamp
|
|
1136
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1137
|
+
const ext = item.type.split('/')[1];
|
|
1138
|
+
const filename = `pasted-image-${timestamp}.${ext}`;
|
|
1139
|
+
|
|
1140
|
+
// Create a new file with the generated name
|
|
1141
|
+
const namedFile = new File([file], filename, { type: item.type });
|
|
1142
|
+
this.handleImageAttachment(namedFile);
|
|
1143
|
+
}
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
updateExternalSessions(externalSessions) {
|
|
1150
|
+
this.externalSessions = externalSessions;
|
|
1151
|
+
// Re-render the session list to include external sessions
|
|
1152
|
+
this.updateSessionList(this.sessions);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
updateSessionList(sessions) {
|
|
1156
|
+
this.sessions = sessions;
|
|
1157
|
+
const select = this.elements.sessionSelect;
|
|
1158
|
+
const tabs = this.elements.sessionTabs;
|
|
1159
|
+
const currentValue = select.value;
|
|
1160
|
+
|
|
1161
|
+
// Helper to extract folder name from path (handles trailing slashes)
|
|
1162
|
+
const getFolderName = (cwd) => {
|
|
1163
|
+
const parts = cwd.split('/').filter(Boolean);
|
|
1164
|
+
return parts[parts.length - 1] || cwd;
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
// Count folder name occurrences to detect duplicates
|
|
1168
|
+
const folderCounts = new Map();
|
|
1169
|
+
for (const session of sessions) {
|
|
1170
|
+
const dirName = getFolderName(session.cwd);
|
|
1171
|
+
folderCounts.set(dirName, (folderCounts.get(dirName) || 0) + 1);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Helper to get display name - just folder, or "ID folder" if duplicate
|
|
1175
|
+
const getDisplayName = (session) => {
|
|
1176
|
+
const dirName = getFolderName(session.cwd);
|
|
1177
|
+
if (folderCounts.get(dirName) > 1) {
|
|
1178
|
+
return `${session.id.slice(0, 3)} ${dirName}`;
|
|
1179
|
+
}
|
|
1180
|
+
return dirName;
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
// Update dropdown (mobile) - use static indicators (no animation to avoid flashing)
|
|
1184
|
+
select.innerHTML = '<option value="">Select session...</option>';
|
|
1185
|
+
for (const session of sessions) {
|
|
1186
|
+
const option = document.createElement('option');
|
|
1187
|
+
option.value = session.id;
|
|
1188
|
+
const indicator = session.activityStatus === 'busy' ? '⏳' : '○';
|
|
1189
|
+
option.textContent = `${indicator} ${getDisplayName(session)}`;
|
|
1190
|
+
option.dataset.status = session.activityStatus || 'unknown';
|
|
1191
|
+
select.appendChild(option);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Add Schedules option to dropdown
|
|
1195
|
+
const scheduleSep = document.createElement('option');
|
|
1196
|
+
scheduleSep.disabled = true;
|
|
1197
|
+
scheduleSep.textContent = '────────────';
|
|
1198
|
+
select.appendChild(scheduleSep);
|
|
1199
|
+
|
|
1200
|
+
const scheduleOpt = document.createElement('option');
|
|
1201
|
+
scheduleOpt.value = 'schedules';
|
|
1202
|
+
const badgeText = this.schedulesBadgeCount > 0 ? ` (${this.schedulesBadgeCount})` : '';
|
|
1203
|
+
scheduleOpt.textContent = `⏰ Schedules${badgeText}`;
|
|
1204
|
+
select.appendChild(scheduleOpt);
|
|
1205
|
+
|
|
1206
|
+
// Add external sessions to dropdown
|
|
1207
|
+
if (this.externalSessions.length > 0) {
|
|
1208
|
+
const separator = document.createElement('option');
|
|
1209
|
+
separator.disabled = true;
|
|
1210
|
+
separator.textContent = '── External Sessions ──';
|
|
1211
|
+
select.appendChild(separator);
|
|
1212
|
+
|
|
1213
|
+
for (const external of this.externalSessions) {
|
|
1214
|
+
const option = document.createElement('option');
|
|
1215
|
+
option.value = `external:${external.pid}`;
|
|
1216
|
+
const indicator = external.activityStatus === 'busy' ? '⏳' : '○';
|
|
1217
|
+
option.textContent = `${indicator} ${getFolderName(external.cwd)}`;
|
|
1218
|
+
option.dataset.status = external.activityStatus || 'unknown';
|
|
1219
|
+
select.appendChild(option);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Update tabs (desktop)
|
|
1224
|
+
if (sessions.length === 0 && this.externalSessions.length === 0) {
|
|
1225
|
+
tabs.innerHTML = '<span class="session-tab-empty">No sessions</span>';
|
|
1226
|
+
} else {
|
|
1227
|
+
let tabsHtml = sessions.map(session => {
|
|
1228
|
+
const isActive = session.id === this.currentSessionId;
|
|
1229
|
+
const indicator = session.activityStatus === 'busy'
|
|
1230
|
+
? SPINNER_FRAMES[this.spinnerFrame]
|
|
1231
|
+
: '●';
|
|
1232
|
+
const statusClass = session.activityStatus || 'unknown';
|
|
1233
|
+
return `<button class="session-tab" role="tab" aria-selected="${isActive}" data-session-id="${session.id}">
|
|
1234
|
+
<span class="activity-indicator" data-status="${statusClass}">${indicator}</span>
|
|
1235
|
+
<span class="session-tab-name">${getDisplayName(session)}</span>
|
|
1236
|
+
<span class="session-tab-close" data-close-session="${session.id}" title="Close session">×</span>
|
|
1237
|
+
</button>`;
|
|
1238
|
+
}).join('');
|
|
1239
|
+
|
|
1240
|
+
// Add external sessions button
|
|
1241
|
+
if (this.externalSessions.length > 0) {
|
|
1242
|
+
const dropdownHtml = this.externalSessions.map(external => {
|
|
1243
|
+
const folderName = getFolderName(external.cwd);
|
|
1244
|
+
const indicator = external.activityStatus === 'busy'
|
|
1245
|
+
? SPINNER_FRAMES[this.spinnerFrame]
|
|
1246
|
+
: '●';
|
|
1247
|
+
const statusClass = external.activityStatus || 'unknown';
|
|
1248
|
+
return `<div class="external-session-item" data-external-pid="${external.pid}" data-external-cwd="${external.cwd}">
|
|
1249
|
+
<span class="activity-indicator" data-status="${statusClass}">${indicator}</span>
|
|
1250
|
+
<div class="external-session-info">
|
|
1251
|
+
<div class="external-session-name">${folderName}</div>
|
|
1252
|
+
<div class="external-session-path">${external.cwd}</div>
|
|
1253
|
+
</div>
|
|
1254
|
+
<div class="external-session-pid">PID ${external.pid}</div>
|
|
1255
|
+
</div>`;
|
|
1256
|
+
}).join('');
|
|
1257
|
+
|
|
1258
|
+
tabsHtml += `<div style="position: relative;">
|
|
1259
|
+
<button class="external-sessions-btn" id="external-sessions-btn" title="External Claude sessions">
|
|
1260
|
+
<span>📍 External</span>
|
|
1261
|
+
<span class="external-sessions-badge">${this.externalSessions.length}</span>
|
|
1262
|
+
</button>
|
|
1263
|
+
<div class="external-sessions-dropdown hidden" id="external-sessions-dropdown">
|
|
1264
|
+
${dropdownHtml}
|
|
1265
|
+
</div>
|
|
1266
|
+
</div>`;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Add Schedules tab button
|
|
1270
|
+
const badgeHtml = this.schedulesBadgeCount > 0
|
|
1271
|
+
? `<span class="schedule-tab-badge">${this.schedulesBadgeCount}</span>`
|
|
1272
|
+
: '';
|
|
1273
|
+
tabsHtml += `<button class="schedule-tab-btn" id="schedules-tab-btn" aria-selected="false">
|
|
1274
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1275
|
+
<circle cx="12" cy="12" r="10"/>
|
|
1276
|
+
<polyline points="12 6 12 12 16 14"/>
|
|
1277
|
+
</svg>
|
|
1278
|
+
<span>Schedules</span>
|
|
1279
|
+
${badgeHtml}
|
|
1280
|
+
</button>`;
|
|
1281
|
+
|
|
1282
|
+
tabs.innerHTML = tabsHtml;
|
|
1283
|
+
|
|
1284
|
+
// Add Schedules tab handler
|
|
1285
|
+
const schedulesTabBtn = document.getElementById('schedules-tab-btn');
|
|
1286
|
+
if (schedulesTabBtn) {
|
|
1287
|
+
schedulesTabBtn.addEventListener('click', () => this.showSchedules());
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Add click handlers for regular sessions
|
|
1291
|
+
tabs.querySelectorAll('.session-tab').forEach(tab => {
|
|
1292
|
+
tab.addEventListener('click', (e) => {
|
|
1293
|
+
// Don't switch session if clicking close button
|
|
1294
|
+
if (e.target.classList.contains('session-tab-close')) return;
|
|
1295
|
+
const sessionId = tab.dataset.sessionId;
|
|
1296
|
+
if (sessionId) this.attachSession(sessionId);
|
|
1297
|
+
});
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
// Add close button handlers
|
|
1301
|
+
tabs.querySelectorAll('.session-tab-close').forEach(btn => {
|
|
1302
|
+
btn.addEventListener('click', (e) => {
|
|
1303
|
+
e.stopPropagation();
|
|
1304
|
+
const sessionId = btn.dataset.closeSession;
|
|
1305
|
+
if (sessionId) this.closeSession(sessionId);
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
// Handle external sessions dropdown
|
|
1310
|
+
if (this.externalSessions.length > 0) {
|
|
1311
|
+
const externalBtn = document.getElementById('external-sessions-btn');
|
|
1312
|
+
const externalDropdown = document.getElementById('external-sessions-dropdown');
|
|
1313
|
+
|
|
1314
|
+
if (externalBtn && externalDropdown) {
|
|
1315
|
+
// Toggle dropdown
|
|
1316
|
+
externalBtn.addEventListener('click', (e) => {
|
|
1317
|
+
e.stopPropagation();
|
|
1318
|
+
const isVisible = externalDropdown.classList.contains('visible');
|
|
1319
|
+
|
|
1320
|
+
if (isVisible) {
|
|
1321
|
+
externalDropdown.classList.remove('visible');
|
|
1322
|
+
externalDropdown.classList.add('hidden');
|
|
1323
|
+
} else {
|
|
1324
|
+
externalDropdown.classList.remove('hidden');
|
|
1325
|
+
externalDropdown.classList.add('visible');
|
|
1326
|
+
|
|
1327
|
+
// Close dropdown when clicking outside (only add when opening)
|
|
1328
|
+
setTimeout(() => {
|
|
1329
|
+
const closeDropdown = (e) => {
|
|
1330
|
+
if (!externalDropdown.contains(e.target) && !externalBtn.contains(e.target)) {
|
|
1331
|
+
externalDropdown.classList.remove('visible');
|
|
1332
|
+
externalDropdown.classList.add('hidden');
|
|
1333
|
+
document.removeEventListener('click', closeDropdown);
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
document.addEventListener('click', closeDropdown);
|
|
1337
|
+
}, 0);
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
// Handle external session adoption
|
|
1342
|
+
externalDropdown.querySelectorAll('.external-session-item').forEach(item => {
|
|
1343
|
+
item.addEventListener('click', (e) => {
|
|
1344
|
+
e.stopPropagation();
|
|
1345
|
+
const pid = parseInt(item.dataset.externalPid, 10);
|
|
1346
|
+
const cwd = item.dataset.externalCwd;
|
|
1347
|
+
if (pid && cwd) {
|
|
1348
|
+
this.adoptExternalSession(pid, cwd);
|
|
1349
|
+
externalDropdown.classList.remove('visible');
|
|
1350
|
+
externalDropdown.classList.add('hidden');
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Restore selection
|
|
1359
|
+
if (this.currentSessionId) {
|
|
1360
|
+
select.value = this.currentSessionId;
|
|
1361
|
+
} else if (currentValue) {
|
|
1362
|
+
select.value = currentValue;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
attachSession(sessionId) {
|
|
1367
|
+
// Save current session's terminal state before switching
|
|
1368
|
+
if (this.currentSessionId && this.serializeAddon) {
|
|
1369
|
+
try {
|
|
1370
|
+
this.sessionCache.set(this.currentSessionId, {
|
|
1371
|
+
content: this.serializeAddon.serialize(),
|
|
1372
|
+
scrollY: this.terminal.buffer.active.viewportY,
|
|
1373
|
+
});
|
|
1374
|
+
} catch (e) {
|
|
1375
|
+
// Ignore serialization errors
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Clear terminal
|
|
1380
|
+
this.terminal.clear();
|
|
1381
|
+
|
|
1382
|
+
// Check if we have cached content for the new session - restore instantly
|
|
1383
|
+
const cached = this.sessionCache.get(sessionId);
|
|
1384
|
+
if (cached) {
|
|
1385
|
+
// Use write callback to restore scroll after content is fully rendered
|
|
1386
|
+
this.terminal.write(cached.content, () => {
|
|
1387
|
+
this.terminal.scrollToLine(cached.scrollY);
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Update tab selection immediately for responsive feel
|
|
1392
|
+
this.updateTabSelection(sessionId);
|
|
1393
|
+
|
|
1394
|
+
// Send attach request - server will send history if we don't have cache
|
|
1395
|
+
// or just start streaming new output if we do
|
|
1396
|
+
this.sendControl({ type: 'session:attach', sessionId, hasCache: !!cached });
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
adoptExternalSession(pid, cwd) {
|
|
1400
|
+
const folderName = cwd.split('/').filter(Boolean).pop() || cwd;
|
|
1401
|
+
if (confirm(`Adopt external Claude session in "${folderName}"?\n\nThis will kill the external process (PID ${pid}) and resume it here with --continue flag.`)) {
|
|
1402
|
+
this.terminal.clear();
|
|
1403
|
+
this.terminal.writeln('\x1b[90mAdopting external session...\x1b[0m');
|
|
1404
|
+
this.sendControl({ type: 'session:adopt', pid, cwd });
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
closeSession(sessionId) {
|
|
1409
|
+
this.sendControl({ type: 'session:destroy', sessionId });
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
closeCurrentSession() {
|
|
1413
|
+
if (this.currentSessionId) {
|
|
1414
|
+
this.closeSession(this.currentSessionId);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
removeSession(sessionId) {
|
|
1419
|
+
// Remove from local sessions list
|
|
1420
|
+
this.sessions = this.sessions.filter(s => s.id !== sessionId);
|
|
1421
|
+
this.updateSessionList(this.sessions);
|
|
1422
|
+
|
|
1423
|
+
// Clean up cached content
|
|
1424
|
+
this.sessionCache.delete(sessionId);
|
|
1425
|
+
|
|
1426
|
+
// If we were attached to this session, clear the terminal
|
|
1427
|
+
if (this.currentSessionId === sessionId) {
|
|
1428
|
+
this.currentSessionId = null;
|
|
1429
|
+
this.terminal.clear();
|
|
1430
|
+
this.terminal.writeln('\x1b[33mSession closed.\x1b[0m');
|
|
1431
|
+
|
|
1432
|
+
// Auto-attach to another session if available
|
|
1433
|
+
if (this.sessions.length > 0) {
|
|
1434
|
+
this.attachSession(this.sessions[0].id);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
updateTabSelection(sessionId) {
|
|
1440
|
+
// Update dropdown
|
|
1441
|
+
this.elements.sessionSelect.value = sessionId || '';
|
|
1442
|
+
|
|
1443
|
+
// Update tabs
|
|
1444
|
+
this.elements.sessionTabs.querySelectorAll('.session-tab').forEach(tab => {
|
|
1445
|
+
const isSelected = tab.dataset.sessionId === sessionId;
|
|
1446
|
+
tab.setAttribute('aria-selected', isSelected);
|
|
1447
|
+
});
|
|
451
1448
|
}
|
|
452
1449
|
|
|
453
1450
|
showNewSessionModal() {
|
|
454
|
-
this.elements.cwdInput.value = '';
|
|
455
1451
|
this.elements.newSessionModal.classList.remove('hidden');
|
|
456
1452
|
this.elements.cwdInput.focus();
|
|
457
1453
|
}
|
|
458
1454
|
|
|
459
1455
|
hideNewSessionModal() {
|
|
460
1456
|
this.elements.newSessionModal.classList.add('hidden');
|
|
1457
|
+
this.elements.cwdInput.value = '';
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
createSession() {
|
|
1461
|
+
const cwd = this.elements.cwdInput.value.trim() || undefined;
|
|
1462
|
+
this.terminal.clear();
|
|
1463
|
+
this.sendControl({ type: 'session:create', cwd });
|
|
1464
|
+
this.hideNewSessionModal();
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async loadPorts() {
|
|
1468
|
+
try {
|
|
1469
|
+
const response = await fetch('/api/ports', {
|
|
1470
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
1471
|
+
});
|
|
1472
|
+
const ports = await response.json();
|
|
1473
|
+
|
|
1474
|
+
const select = this.elements.portSelect;
|
|
1475
|
+
select.innerHTML = '<option value="">Select port...</option>';
|
|
1476
|
+
|
|
1477
|
+
for (const port of ports) {
|
|
1478
|
+
const option = document.createElement('option');
|
|
1479
|
+
option.value = port.port;
|
|
1480
|
+
option.textContent = `${port.port} - ${port.process}`;
|
|
1481
|
+
select.appendChild(option);
|
|
1482
|
+
}
|
|
1483
|
+
} catch (err) {
|
|
1484
|
+
console.error('Failed to load ports:', err);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
showPreview() {
|
|
1489
|
+
this.showScreen('preview-screen');
|
|
1490
|
+
this.loadPorts();
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
hidePreview() {
|
|
1494
|
+
this.showScreen('main-screen');
|
|
1495
|
+
this.elements.previewFrame.src = 'about:blank';
|
|
1496
|
+
this.elements.addressInput.value = '';
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
loadPreview(port, path = '/') {
|
|
1500
|
+
if (!port) return;
|
|
1501
|
+
// Normalize the path
|
|
1502
|
+
if (!path.startsWith('/')) {
|
|
1503
|
+
path = '/' + path;
|
|
1504
|
+
}
|
|
1505
|
+
// Store the current port for address bar navigation
|
|
1506
|
+
this.currentPreviewPort = port;
|
|
1507
|
+
// Update the address bar
|
|
1508
|
+
this.elements.addressInput.value = path;
|
|
1509
|
+
// Build the URL with the path
|
|
1510
|
+
const encodedPath = path === '/' ? '/' : path;
|
|
1511
|
+
this.elements.previewFrame.src = `/preview/${port}${encodedPath}?token=${encodeURIComponent(this.token)}`;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
loadCustomPort() {
|
|
1515
|
+
const port = this.elements.portInput.value.trim();
|
|
1516
|
+
if (port && port > 0 && port <= 65535) {
|
|
1517
|
+
this.elements.addressInput.value = '/';
|
|
1518
|
+
this.loadPreview(port, '/');
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
navigateToAddress() {
|
|
1523
|
+
const port = this.currentPreviewPort || this.elements.portInput.value || this.elements.portSelect.value;
|
|
1524
|
+
const path = this.elements.addressInput.value.trim() || '/';
|
|
1525
|
+
if (port) {
|
|
1526
|
+
this.loadPreview(port, path);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
refreshPreview() {
|
|
1531
|
+
const port = this.currentPreviewPort || this.elements.portInput.value || this.elements.portSelect.value;
|
|
1532
|
+
const path = this.elements.addressInput.value.trim() || '/';
|
|
1533
|
+
if (port) {
|
|
1534
|
+
this.loadPreview(port, path);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Autocomplete methods
|
|
1539
|
+
onCwdInput() {
|
|
1540
|
+
clearTimeout(this.debounceTimer);
|
|
1541
|
+
this.debounceTimer = setTimeout(() => this.fetchSuggestions(), 150);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async fetchSuggestions() {
|
|
1545
|
+
const value = this.elements.cwdInput.value;
|
|
1546
|
+
if (!value) {
|
|
1547
|
+
this.hideSuggestions();
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
try {
|
|
1552
|
+
const response = await fetch(`/api/dirs?path=${encodeURIComponent(value)}`, {
|
|
1553
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
1554
|
+
});
|
|
1555
|
+
this.suggestions = await response.json();
|
|
1556
|
+
this.selectedSuggestionIndex = -1;
|
|
1557
|
+
this.renderSuggestions();
|
|
1558
|
+
} catch (err) {
|
|
1559
|
+
console.error('Failed to fetch suggestions:', err);
|
|
1560
|
+
this.hideSuggestions();
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
renderSuggestions() {
|
|
1565
|
+
const ul = this.elements.cwdSuggestions;
|
|
1566
|
+
|
|
1567
|
+
if (this.suggestions.length === 0) {
|
|
1568
|
+
this.hideSuggestions();
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
ul.innerHTML = this.suggestions.map((s, i) => `
|
|
1573
|
+
<li role="option" data-index="${i}" class="${i === this.selectedSuggestionIndex ? 'selected' : ''}">
|
|
1574
|
+
<span class="dir-name">${s.name}/</span>
|
|
1575
|
+
</li>
|
|
1576
|
+
`).join('');
|
|
1577
|
+
|
|
1578
|
+
// Add click handlers - use mousedown to prevent blur from firing
|
|
1579
|
+
ul.querySelectorAll('li').forEach(li => {
|
|
1580
|
+
li.addEventListener('mousedown', (e) => {
|
|
1581
|
+
e.preventDefault(); // Prevent blur on input
|
|
1582
|
+
const index = parseInt(li.dataset.index, 10);
|
|
1583
|
+
this.selectSuggestion(index);
|
|
1584
|
+
});
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
ul.classList.remove('hidden');
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
hideSuggestions() {
|
|
1591
|
+
this.elements.cwdSuggestions.classList.add('hidden');
|
|
1592
|
+
this.suggestions = [];
|
|
1593
|
+
this.selectedSuggestionIndex = -1;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
selectSuggestion(index) {
|
|
1597
|
+
if (index >= 0 && index < this.suggestions.length) {
|
|
1598
|
+
this.elements.cwdInput.value = this.suggestions[index].path;
|
|
1599
|
+
this.hideSuggestions();
|
|
1600
|
+
// Refocus input and trigger another fetch for subdirectories
|
|
1601
|
+
this.elements.cwdInput.focus();
|
|
1602
|
+
this.fetchSuggestions();
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
onCwdKeydown(e) {
|
|
1607
|
+
// Handle Enter even when no suggestions
|
|
1608
|
+
if (e.key === 'Enter' && this.suggestions.length === 0) {
|
|
1609
|
+
this.createSession();
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
if (this.suggestions.length === 0) return;
|
|
1614
|
+
|
|
1615
|
+
switch (e.key) {
|
|
1616
|
+
case 'ArrowDown':
|
|
1617
|
+
e.preventDefault();
|
|
1618
|
+
this.selectedSuggestionIndex = Math.min(
|
|
1619
|
+
this.selectedSuggestionIndex + 1,
|
|
1620
|
+
this.suggestions.length - 1
|
|
1621
|
+
);
|
|
1622
|
+
this.renderSuggestions();
|
|
1623
|
+
break;
|
|
1624
|
+
|
|
1625
|
+
case 'ArrowUp':
|
|
1626
|
+
e.preventDefault();
|
|
1627
|
+
this.selectedSuggestionIndex = Math.max(this.selectedSuggestionIndex - 1, -1);
|
|
1628
|
+
this.renderSuggestions();
|
|
1629
|
+
break;
|
|
1630
|
+
|
|
1631
|
+
case 'Tab':
|
|
1632
|
+
if (this.selectedSuggestionIndex >= 0) {
|
|
1633
|
+
e.preventDefault();
|
|
1634
|
+
this.selectSuggestion(this.selectedSuggestionIndex);
|
|
1635
|
+
} else if (this.suggestions.length > 0) {
|
|
1636
|
+
e.preventDefault();
|
|
1637
|
+
this.selectSuggestion(0);
|
|
1638
|
+
}
|
|
1639
|
+
break;
|
|
1640
|
+
|
|
1641
|
+
case 'Enter':
|
|
1642
|
+
if (this.selectedSuggestionIndex >= 0) {
|
|
1643
|
+
e.preventDefault();
|
|
1644
|
+
this.selectSuggestion(this.selectedSuggestionIndex);
|
|
1645
|
+
} else {
|
|
1646
|
+
// No suggestion selected - submit the form
|
|
1647
|
+
this.hideSuggestions();
|
|
1648
|
+
this.createSession();
|
|
1649
|
+
}
|
|
1650
|
+
break;
|
|
1651
|
+
|
|
1652
|
+
case 'Escape':
|
|
1653
|
+
this.hideSuggestions();
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// Mobile keys methods
|
|
1659
|
+
initMobileKeys() {
|
|
1660
|
+
// Only init on touch devices
|
|
1661
|
+
if (window.matchMedia('(pointer: fine)').matches) return;
|
|
1662
|
+
|
|
1663
|
+
// Handle mobile key button clicks
|
|
1664
|
+
this.elements.mobileKeys.addEventListener('click', (e) => {
|
|
1665
|
+
const btn = e.target.closest('.mobile-key');
|
|
1666
|
+
if (!btn) return;
|
|
1667
|
+
|
|
1668
|
+
const key = btn.dataset.key;
|
|
1669
|
+
this.handleMobileKey(key);
|
|
1670
|
+
|
|
1671
|
+
// Keep terminal focused
|
|
1672
|
+
this.terminal.focus();
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
// Add touch handlers for visual feedback (iOS doesn't always trigger :active)
|
|
1676
|
+
this.elements.mobileKeys.addEventListener('touchstart', (e) => {
|
|
1677
|
+
const btn = e.target.closest('.mobile-key');
|
|
1678
|
+
if (btn) btn.classList.add('pressed');
|
|
1679
|
+
}, { passive: true });
|
|
1680
|
+
|
|
1681
|
+
this.elements.mobileKeys.addEventListener('touchend', () => {
|
|
1682
|
+
this.elements.mobileKeys.querySelectorAll('.mobile-key.pressed').forEach(btn => {
|
|
1683
|
+
btn.classList.remove('pressed');
|
|
1684
|
+
});
|
|
1685
|
+
}, { passive: true });
|
|
1686
|
+
|
|
1687
|
+
this.elements.mobileKeys.addEventListener('touchcancel', () => {
|
|
1688
|
+
this.elements.mobileKeys.querySelectorAll('.mobile-key.pressed').forEach(btn => {
|
|
1689
|
+
btn.classList.remove('pressed');
|
|
1690
|
+
});
|
|
1691
|
+
}, { passive: true });
|
|
1692
|
+
|
|
1693
|
+
// Detect keyboard visibility using visualViewport API
|
|
1694
|
+
if (window.visualViewport) {
|
|
1695
|
+
window.visualViewport.addEventListener('resize', () => this.onViewportChange());
|
|
1696
|
+
window.visualViewport.addEventListener('scroll', () => this.onViewportChange());
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
onViewportChange() {
|
|
1701
|
+
const viewport = window.visualViewport;
|
|
1702
|
+
const heightDiff = window.innerHeight - viewport.height;
|
|
1703
|
+
|
|
1704
|
+
// If viewport is significantly smaller than window, keyboard is likely open
|
|
1705
|
+
// Using 150px threshold to account for keyboard
|
|
1706
|
+
const keyboardOpen = heightDiff > 150;
|
|
1707
|
+
|
|
1708
|
+
if (keyboardOpen && this.elements.mainScreen.classList.contains('active')) {
|
|
1709
|
+
// Position toolbar just above the keyboard
|
|
1710
|
+
// Account for visual viewport offset when page is scrolled
|
|
1711
|
+
const keyboardHeight = window.innerHeight - viewport.height - viewport.offsetTop;
|
|
1712
|
+
this.elements.mobileKeys.style.bottom = `${Math.max(0, keyboardHeight)}px`;
|
|
1713
|
+
this.showMobileKeys();
|
|
1714
|
+
} else {
|
|
1715
|
+
this.hideMobileKeys();
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
showMobileKeys() {
|
|
1720
|
+
this.elements.mobileKeys.classList.remove('hidden');
|
|
1721
|
+
this.elements.mobileKeys.classList.add('visible');
|
|
1722
|
+
this.elements.mainScreen.classList.add('mobile-keys-visible');
|
|
1723
|
+
this.fitTerminal();
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
hideMobileKeys() {
|
|
1727
|
+
this.elements.mobileKeys.classList.remove('visible');
|
|
1728
|
+
this.elements.mainScreen.classList.remove('mobile-keys-visible');
|
|
1729
|
+
// Reset modifier states when hiding
|
|
1730
|
+
this.setCtrlActive(false);
|
|
1731
|
+
this.setShiftActive(false);
|
|
1732
|
+
this.fitTerminal();
|
|
1733
|
+
// Hide after animation
|
|
1734
|
+
setTimeout(() => {
|
|
1735
|
+
if (!this.elements.mobileKeys.classList.contains('visible')) {
|
|
1736
|
+
this.elements.mobileKeys.classList.add('hidden');
|
|
1737
|
+
}
|
|
1738
|
+
}, 300);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
handleMobileKey(key) {
|
|
1742
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.currentSessionId) return;
|
|
1743
|
+
|
|
1744
|
+
switch (key) {
|
|
1745
|
+
case 'escape':
|
|
1746
|
+
this.ws.send('\x1b'); // ESC
|
|
1747
|
+
break;
|
|
1748
|
+
case 'ctrl':
|
|
1749
|
+
this.setCtrlActive(!this.ctrlActive);
|
|
1750
|
+
return; // Don't send anything, just toggle state
|
|
1751
|
+
case 'shift':
|
|
1752
|
+
this.setShiftActive(!this.shiftActive);
|
|
1753
|
+
return; // Don't send anything, just toggle state
|
|
1754
|
+
case 'tab':
|
|
1755
|
+
this.ws.send(this.shiftActive ? '\x1b[Z' : '\t'); // Shift+Tab or Tab
|
|
1756
|
+
break;
|
|
1757
|
+
case 'slash':
|
|
1758
|
+
this.ws.send('/');
|
|
1759
|
+
break;
|
|
1760
|
+
case 'up':
|
|
1761
|
+
this.ws.send('\x1b[A'); // Arrow up
|
|
1762
|
+
break;
|
|
1763
|
+
case 'down':
|
|
1764
|
+
this.ws.send('\x1b[B'); // Arrow down
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// Clear modifiers after sending a key (except when toggling modifiers)
|
|
1769
|
+
if (key !== 'ctrl' && key !== 'shift') {
|
|
1770
|
+
if (this.ctrlActive) this.setCtrlActive(false);
|
|
1771
|
+
if (this.shiftActive) this.setShiftActive(false);
|
|
1772
|
+
}
|
|
461
1773
|
}
|
|
462
1774
|
|
|
463
1775
|
setCtrlActive(active) {
|
|
464
1776
|
this.ctrlActive = active;
|
|
465
|
-
|
|
1777
|
+
const ctrlBtn = this.elements.mobileKeys.querySelector('[data-key="ctrl"]');
|
|
1778
|
+
if (ctrlBtn) {
|
|
1779
|
+
ctrlBtn.setAttribute('aria-pressed', active);
|
|
1780
|
+
}
|
|
466
1781
|
}
|
|
467
1782
|
|
|
468
1783
|
setShiftActive(active) {
|
|
469
1784
|
this.shiftActive = active;
|
|
470
|
-
|
|
1785
|
+
const shiftBtn = this.elements.mobileKeys.querySelector('[data-key="shift"]');
|
|
1786
|
+
if (shiftBtn) {
|
|
1787
|
+
shiftBtn.setAttribute('aria-pressed', active);
|
|
1788
|
+
}
|
|
471
1789
|
}
|
|
472
|
-
}
|
|
473
1790
|
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
1791
|
+
// Scroll button methods
|
|
1792
|
+
updateScrollButton() {
|
|
1793
|
+
if (!this.terminal) return;
|
|
1794
|
+
|
|
1795
|
+
const buffer = this.terminal.buffer.active;
|
|
1796
|
+
const totalLines = buffer.baseY + this.terminal.rows;
|
|
1797
|
+
const currentScroll = buffer.viewportY;
|
|
1798
|
+
const maxScroll = buffer.baseY;
|
|
1799
|
+
|
|
1800
|
+
// Show button if not at bottom (with small threshold for rounding)
|
|
1801
|
+
const isAtBottom = currentScroll >= maxScroll - 1;
|
|
1802
|
+
|
|
1803
|
+
if (isAtBottom) {
|
|
1804
|
+
this.elements.scrollBottomBtn.classList.add('hidden');
|
|
1805
|
+
} else {
|
|
1806
|
+
this.elements.scrollBottomBtn.classList.remove('hidden');
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
scrollToBottom() {
|
|
1811
|
+
if (this.terminal) {
|
|
1812
|
+
this.terminal.scrollToBottom();
|
|
1813
|
+
this.elements.scrollBottomBtn.classList.add('hidden');
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
// Update session activity status without full re-render
|
|
1818
|
+
updateSessionStatus(sessions, externalSessions) {
|
|
1819
|
+
let statusChanged = false;
|
|
1820
|
+
|
|
1821
|
+
// Update stored session data
|
|
1822
|
+
for (const session of sessions) {
|
|
1823
|
+
const existing = this.sessions.find(s => s.id === session.id);
|
|
1824
|
+
if (existing && existing.activityStatus !== session.activityStatus) {
|
|
1825
|
+
existing.activityStatus = session.activityStatus;
|
|
1826
|
+
statusChanged = true;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
// Update external sessions data
|
|
1830
|
+
if (externalSessions) {
|
|
1831
|
+
for (const external of externalSessions) {
|
|
1832
|
+
const existing = this.externalSessions.find(s => s.pid === external.pid);
|
|
1833
|
+
if (existing && existing.activityStatus !== external.activityStatus) {
|
|
1834
|
+
existing.activityStatus = external.activityStatus;
|
|
1835
|
+
statusChanged = true;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// Update status indicators in the DOM
|
|
1841
|
+
this.updateActivityIndicators();
|
|
1842
|
+
|
|
1843
|
+
// Update dropdown options only when status actually changes
|
|
1844
|
+
if (statusChanged) {
|
|
1845
|
+
this.updateDropdownStatus();
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// Update dropdown option indicators (called only on status change, not animation)
|
|
1850
|
+
updateDropdownStatus() {
|
|
1851
|
+
const options = this.elements.sessionSelect.querySelectorAll('option');
|
|
1852
|
+
options.forEach(option => {
|
|
1853
|
+
const sessionId = option.value;
|
|
1854
|
+
if (!sessionId) return;
|
|
1855
|
+
|
|
1856
|
+
if (sessionId.startsWith('external:')) {
|
|
1857
|
+
const pid = parseInt(sessionId.replace('external:', ''), 10);
|
|
1858
|
+
const external = this.externalSessions.find(s => s.pid === pid);
|
|
1859
|
+
if (external) {
|
|
1860
|
+
const indicator = external.activityStatus === 'busy' ? '⏳' : '○';
|
|
1861
|
+
const folderName = external.cwd.split('/').filter(Boolean).pop() || external.cwd;
|
|
1862
|
+
option.textContent = `${indicator} ${folderName}`;
|
|
1863
|
+
option.dataset.status = external.activityStatus || 'unknown';
|
|
1864
|
+
}
|
|
1865
|
+
} else {
|
|
1866
|
+
const session = this.sessions.find(s => s.id === sessionId);
|
|
1867
|
+
if (session) {
|
|
1868
|
+
const indicator = session.activityStatus === 'busy' ? '⏳' : '○';
|
|
1869
|
+
option.textContent = `${indicator} ${this.getDisplayName(session)}`;
|
|
1870
|
+
option.dataset.status = session.activityStatus || 'unknown';
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// Update all activity status indicators in tabs and dropdown
|
|
1877
|
+
updateActivityIndicators() {
|
|
1878
|
+
// Update tabs
|
|
1879
|
+
this.elements.sessionTabs.querySelectorAll('.session-tab').forEach(tab => {
|
|
1880
|
+
const sessionId = tab.dataset.sessionId;
|
|
1881
|
+
const session = this.sessions.find(s => s.id === sessionId);
|
|
1882
|
+
const indicator = tab.querySelector('.activity-indicator');
|
|
1883
|
+
if (indicator && session) {
|
|
1884
|
+
indicator.dataset.status = session.activityStatus || 'unknown';
|
|
1885
|
+
if (session.activityStatus === 'busy') {
|
|
1886
|
+
indicator.textContent = SPINNER_FRAMES[this.spinnerFrame];
|
|
1887
|
+
} else {
|
|
1888
|
+
indicator.textContent = '●';
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
// Note: We don't animate dropdown <option> elements - they use static indicators
|
|
1894
|
+
// set during updateSessionList(). Animating them causes flashing on mobile.
|
|
1895
|
+
|
|
1896
|
+
// Update external session items
|
|
1897
|
+
const externalItems = document.querySelectorAll('.external-session-item');
|
|
1898
|
+
externalItems.forEach(item => {
|
|
1899
|
+
const cwd = item.dataset.externalCwd;
|
|
1900
|
+
const external = this.externalSessions.find(s => s.cwd === cwd);
|
|
1901
|
+
const indicator = item.querySelector('.activity-indicator');
|
|
1902
|
+
if (indicator && external) {
|
|
1903
|
+
indicator.dataset.status = external.activityStatus || 'unknown';
|
|
1904
|
+
if (external.activityStatus === 'busy') {
|
|
1905
|
+
indicator.textContent = SPINNER_FRAMES[this.spinnerFrame];
|
|
1906
|
+
} else {
|
|
1907
|
+
indicator.textContent = '●';
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// Update spinner frames for busy sessions
|
|
1914
|
+
updateSpinnerFrames() {
|
|
1915
|
+
// Only update if there are busy sessions
|
|
1916
|
+
const hasBusy = this.sessions.some(s => s.activityStatus === 'busy') ||
|
|
1917
|
+
this.externalSessions.some(s => s.activityStatus === 'busy');
|
|
1918
|
+
if (hasBusy) {
|
|
1919
|
+
this.updateActivityIndicators();
|
|
495
1920
|
}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// ===== SCHEDULE METHODS =====
|
|
1924
|
+
|
|
1925
|
+
showSchedules() {
|
|
1926
|
+
this.showScreen('schedules-screen');
|
|
1927
|
+
this.schedulesBadgeCount = 0;
|
|
1928
|
+
this.schedulesLastViewed = Date.now();
|
|
1929
|
+
this.updateScheduleBadge();
|
|
1930
|
+
this.sendControl({ type: 'schedule:list' });
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
hideSchedules() {
|
|
1934
|
+
this.showScreen('main-screen');
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
renderSchedules() {
|
|
1938
|
+
const list = this.elements.schedulesList;
|
|
1939
|
+
|
|
1940
|
+
if (this.schedules.length === 0) {
|
|
1941
|
+
this.elements.schedulesEmpty.style.display = '';
|
|
1942
|
+
// Remove all cards but keep empty state
|
|
1943
|
+
list.querySelectorAll('.schedule-card').forEach(c => c.remove());
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
this.elements.schedulesEmpty.style.display = 'none';
|
|
1948
|
+
|
|
1949
|
+
// Build cards HTML
|
|
1950
|
+
let html = '';
|
|
1951
|
+
for (const schedule of this.schedules) {
|
|
1952
|
+
const lastRunHtml = schedule.lastRun
|
|
1953
|
+
? `<div class="schedule-last-run">
|
|
1954
|
+
<span class="run-status ${schedule.lastRun.exitCode === 0 ? 'success' : 'failure'}"></span>
|
|
1955
|
+
<span>${schedule.lastRun.exitCode === 0 ? 'Success' : 'Failed'} - ${this.formatRunTimestamp(schedule.lastRun.timestamp)}</span>
|
|
1956
|
+
</div>`
|
|
1957
|
+
: `<div class="schedule-last-run"><span class="run-status pending"></span><span>No runs yet</span></div>`;
|
|
1958
|
+
|
|
1959
|
+
const runsData = this.expandedScheduleRuns.get(schedule.id);
|
|
1960
|
+
let runsHtml = '';
|
|
1961
|
+
if (runsData) {
|
|
1962
|
+
if (runsData.length === 0) {
|
|
1963
|
+
runsHtml = `<div class="schedule-runs-list"><div class="run-item"><span style="color:var(--text-muted)">No runs recorded</span></div></div>`;
|
|
1964
|
+
} else {
|
|
1965
|
+
runsHtml = `<div class="schedule-runs-list">${runsData.map(run => {
|
|
1966
|
+
const statusClass = run.exitCode === 0 ? 'success' : (run.exitCode === null ? 'running' : 'failure');
|
|
1967
|
+
const duration = run.durationMs ? this.formatDuration(run.durationMs) : '...';
|
|
1968
|
+
return `<div class="run-item" data-schedule-id="${schedule.id}" data-run-timestamp="${run.timestamp}">
|
|
1969
|
+
<span class="run-item-status ${statusClass}"></span>
|
|
1970
|
+
<span class="run-item-time">${this.formatRunTimestamp(run.timestamp)}</span>
|
|
1971
|
+
<span class="run-item-duration">${duration}</span>
|
|
1972
|
+
</div>`;
|
|
1973
|
+
}).join('')}</div>`;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
const toggleLabel = runsData ? 'Hide runs' : 'View runs';
|
|
1978
|
+
|
|
1979
|
+
html += `<div class="schedule-card" data-schedule-id="${schedule.id}">
|
|
1980
|
+
<div class="schedule-card-header">
|
|
1981
|
+
<span class="schedule-card-name">${this.escapeHtml(schedule.name)}</span>
|
|
1982
|
+
<div class="schedule-card-actions">
|
|
1983
|
+
<button class="schedule-trigger-btn" data-trigger-id="${schedule.id}" title="Run now">
|
|
1984
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
|
1985
|
+
<polygon points="6 3 20 12 6 21 6 3"/>
|
|
1986
|
+
</svg>
|
|
1987
|
+
</button>
|
|
1988
|
+
<button class="toggle schedule-toggle" role="switch" aria-checked="${schedule.enabled}" data-toggle-id="${schedule.id}">
|
|
1989
|
+
<span class="toggle-track"></span>
|
|
1990
|
+
<span class="toggle-thumb"></span>
|
|
1991
|
+
</button>
|
|
1992
|
+
<button class="schedule-delete-btn" data-delete-id="${schedule.id}" title="Delete schedule">
|
|
1993
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1994
|
+
<polyline points="3 6 5 6 21 6"/>
|
|
1995
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
1996
|
+
</svg>
|
|
1997
|
+
</button>
|
|
1998
|
+
</div>
|
|
1999
|
+
</div>
|
|
2000
|
+
<div class="schedule-card-meta">
|
|
2001
|
+
<div class="schedule-meta-row">
|
|
2002
|
+
<span class="schedule-meta-label">Prompt</span>
|
|
2003
|
+
<span class="schedule-meta-value prompt-value">${this.escapeHtml(schedule.prompt)}</span>
|
|
2004
|
+
</div>
|
|
2005
|
+
<div class="schedule-meta-row">
|
|
2006
|
+
<span class="schedule-meta-label">Dir</span>
|
|
2007
|
+
<span class="schedule-meta-value">${this.escapeHtml(schedule.cwd)}</span>
|
|
2008
|
+
</div>
|
|
2009
|
+
<div class="schedule-meta-row">
|
|
2010
|
+
<span class="schedule-meta-label">Schedule</span>
|
|
2011
|
+
<span class="schedule-meta-value">${this.escapeHtml(schedule.presetLabel)} <span style="color:var(--text-muted)">(${this.escapeHtml(schedule.cronExpression)})</span></span>
|
|
2012
|
+
</div>
|
|
2013
|
+
<div class="schedule-meta-row">
|
|
2014
|
+
<span class="schedule-meta-label">Last Run</span>
|
|
2015
|
+
${lastRunHtml}
|
|
2016
|
+
</div>
|
|
2017
|
+
</div>
|
|
2018
|
+
<button class="schedule-runs-toggle" data-runs-id="${schedule.id}">${toggleLabel}</button>
|
|
2019
|
+
${runsHtml}
|
|
2020
|
+
</div>`;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// Remove existing cards, keep empty state element
|
|
2024
|
+
list.querySelectorAll('.schedule-card').forEach(c => c.remove());
|
|
2025
|
+
list.insertAdjacentHTML('beforeend', html);
|
|
2026
|
+
|
|
2027
|
+
// Bind event handlers
|
|
2028
|
+
list.querySelectorAll('.schedule-toggle').forEach(btn => {
|
|
2029
|
+
btn.addEventListener('click', () => {
|
|
2030
|
+
const id = btn.dataset.toggleId;
|
|
2031
|
+
const currentEnabled = btn.getAttribute('aria-checked') === 'true';
|
|
2032
|
+
this.toggleSchedule(id, !currentEnabled);
|
|
2033
|
+
});
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
list.querySelectorAll('.schedule-trigger-btn').forEach(btn => {
|
|
2037
|
+
btn.addEventListener('click', () => {
|
|
2038
|
+
const id = btn.dataset.triggerId;
|
|
2039
|
+
this.triggerSchedule(id);
|
|
2040
|
+
});
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
list.querySelectorAll('.schedule-delete-btn').forEach(btn => {
|
|
2044
|
+
btn.addEventListener('click', () => {
|
|
2045
|
+
const id = btn.dataset.deleteId;
|
|
2046
|
+
this.deleteSchedule(id);
|
|
2047
|
+
});
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
list.querySelectorAll('.schedule-runs-toggle').forEach(btn => {
|
|
2051
|
+
btn.addEventListener('click', () => {
|
|
2052
|
+
const id = btn.dataset.runsId;
|
|
2053
|
+
this.viewRuns(id);
|
|
2054
|
+
});
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
list.querySelectorAll('.run-item').forEach(item => {
|
|
2058
|
+
item.addEventListener('click', () => {
|
|
2059
|
+
const scheduleId = item.dataset.scheduleId;
|
|
2060
|
+
const timestamp = item.dataset.runTimestamp;
|
|
2061
|
+
if (scheduleId && timestamp) {
|
|
2062
|
+
this.viewRunLog(scheduleId, timestamp);
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
updateScheduleBadge() {
|
|
2069
|
+
const headerBadge = this.elements.schedulesHeaderBadge;
|
|
2070
|
+
if (this.schedulesBadgeCount > 0) {
|
|
2071
|
+
headerBadge.textContent = this.schedulesBadgeCount;
|
|
2072
|
+
headerBadge.classList.remove('hidden');
|
|
2073
|
+
} else {
|
|
2074
|
+
headerBadge.classList.add('hidden');
|
|
2075
|
+
}
|
|
2076
|
+
// Re-render session list to update tab badge
|
|
2077
|
+
this.updateSessionList(this.sessions);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
showNewScheduleModal() {
|
|
2081
|
+
this.elements.newScheduleModal.classList.remove('hidden');
|
|
2082
|
+
this.elements.scheduleNameInput.focus();
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
hideNewScheduleModal() {
|
|
2086
|
+
this.elements.newScheduleModal.classList.add('hidden');
|
|
2087
|
+
this.elements.scheduleNameInput.value = '';
|
|
2088
|
+
this.elements.schedulePromptInput.value = '';
|
|
2089
|
+
this.elements.scheduleCwdInput.value = '';
|
|
2090
|
+
this.elements.scheduleTextInput.value = '';
|
|
2091
|
+
this.resetScheduleCreateBtn();
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
resetScheduleCreateBtn() {
|
|
2095
|
+
const createBtn = document.getElementById('create-schedule-btn');
|
|
2096
|
+
const cancelBtn = document.getElementById('cancel-schedule-btn');
|
|
2097
|
+
if (createBtn) {
|
|
2098
|
+
createBtn.textContent = 'Create';
|
|
2099
|
+
createBtn.disabled = false;
|
|
2100
|
+
}
|
|
2101
|
+
if (cancelBtn) cancelBtn.disabled = false;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
createSchedule() {
|
|
2105
|
+
const name = this.elements.scheduleNameInput.value.trim();
|
|
2106
|
+
const prompt = this.elements.schedulePromptInput.value.trim();
|
|
2107
|
+
const cwd = this.elements.scheduleCwdInput.value.trim();
|
|
2108
|
+
const scheduleText = this.elements.scheduleTextInput.value.trim();
|
|
2109
|
+
|
|
2110
|
+
if (!name || !prompt || !cwd || !scheduleText) {
|
|
2111
|
+
return; // form validation - all fields required
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// Show loading state
|
|
2115
|
+
const createBtn = document.getElementById('create-schedule-btn');
|
|
2116
|
+
const cancelBtn = document.getElementById('cancel-schedule-btn');
|
|
2117
|
+
createBtn.textContent = 'Creating...';
|
|
2118
|
+
createBtn.disabled = true;
|
|
2119
|
+
cancelBtn.disabled = true;
|
|
2120
|
+
|
|
2121
|
+
this.sendControl({ type: 'schedule:create', name, prompt, cwd, scheduleText });
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
toggleSchedule(id, enabled) {
|
|
2125
|
+
this.sendControl({ type: 'schedule:update', scheduleId: id, enabled });
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
triggerSchedule(id) {
|
|
2129
|
+
this.sendControl({ type: 'schedule:trigger', scheduleId: id });
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
deleteSchedule(id) {
|
|
2133
|
+
const schedule = this.schedules.find(s => s.id === id);
|
|
2134
|
+
const name = schedule ? schedule.name : id;
|
|
2135
|
+
if (confirm(`Delete schedule "${name}"?`)) {
|
|
2136
|
+
this.expandedScheduleRuns.delete(id);
|
|
2137
|
+
this.sendControl({ type: 'schedule:delete', scheduleId: id });
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
viewRuns(scheduleId) {
|
|
2142
|
+
if (this.expandedScheduleRuns.has(scheduleId)) {
|
|
2143
|
+
// Collapse
|
|
2144
|
+
this.expandedScheduleRuns.delete(scheduleId);
|
|
2145
|
+
this.renderSchedules();
|
|
2146
|
+
} else {
|
|
2147
|
+
// Expand - request runs from server
|
|
2148
|
+
this.sendControl({ type: 'schedule:runs', scheduleId });
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
viewRunLog(scheduleId, timestamp) {
|
|
2153
|
+
this.sendControl({ type: 'schedule:log', scheduleId, timestamp });
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
showRunLogModal(content) {
|
|
2157
|
+
this.elements.runLogPre.textContent = content;
|
|
2158
|
+
this.elements.runLogModal.classList.remove('hidden');
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
hideRunLogModal() {
|
|
2162
|
+
this.elements.runLogModal.classList.add('hidden');
|
|
2163
|
+
this.elements.runLogPre.textContent = '';
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// Schedule CWD autocomplete
|
|
2167
|
+
onScheduleCwdInput() {
|
|
2168
|
+
clearTimeout(this.scheduleDebounceTimer);
|
|
2169
|
+
this.scheduleDebounceTimer = setTimeout(() => this.fetchScheduleSuggestions(), 150);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
async fetchScheduleSuggestions() {
|
|
2173
|
+
const value = this.elements.scheduleCwdInput.value;
|
|
2174
|
+
if (!value) {
|
|
2175
|
+
this.hideScheduleSuggestions();
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
try {
|
|
2180
|
+
const response = await fetch(`/api/dirs?path=${encodeURIComponent(value)}`, {
|
|
2181
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
2182
|
+
});
|
|
2183
|
+
this.scheduleSuggestions = await response.json();
|
|
2184
|
+
this.scheduleSelectedIndex = -1;
|
|
2185
|
+
this.renderScheduleSuggestions();
|
|
2186
|
+
} catch (err) {
|
|
2187
|
+
this.hideScheduleSuggestions();
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
renderScheduleSuggestions() {
|
|
2192
|
+
const ul = this.elements.scheduleCwdSuggestions;
|
|
2193
|
+
|
|
2194
|
+
if (this.scheduleSuggestions.length === 0) {
|
|
2195
|
+
this.hideScheduleSuggestions();
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
ul.innerHTML = this.scheduleSuggestions.map((s, i) => `
|
|
2200
|
+
<li role="option" data-index="${i}" class="${i === this.scheduleSelectedIndex ? 'selected' : ''}">
|
|
2201
|
+
<span class="dir-name">${s.name}/</span>
|
|
2202
|
+
</li>
|
|
2203
|
+
`).join('');
|
|
2204
|
+
|
|
2205
|
+
ul.querySelectorAll('li').forEach(li => {
|
|
2206
|
+
li.addEventListener('mousedown', (e) => {
|
|
2207
|
+
e.preventDefault();
|
|
2208
|
+
const index = parseInt(li.dataset.index, 10);
|
|
2209
|
+
this.selectScheduleSuggestion(index);
|
|
2210
|
+
});
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
ul.classList.remove('hidden');
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
hideScheduleSuggestions() {
|
|
2217
|
+
this.elements.scheduleCwdSuggestions.classList.add('hidden');
|
|
2218
|
+
this.scheduleSuggestions = [];
|
|
2219
|
+
this.scheduleSelectedIndex = -1;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
selectScheduleSuggestion(index) {
|
|
2223
|
+
if (index >= 0 && index < this.scheduleSuggestions.length) {
|
|
2224
|
+
this.elements.scheduleCwdInput.value = this.scheduleSuggestions[index].path;
|
|
2225
|
+
this.hideScheduleSuggestions();
|
|
2226
|
+
this.elements.scheduleCwdInput.focus();
|
|
2227
|
+
this.fetchScheduleSuggestions();
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
onScheduleCwdKeydown(e) {
|
|
2232
|
+
if (this.scheduleSuggestions.length === 0) return;
|
|
2233
|
+
|
|
2234
|
+
switch (e.key) {
|
|
2235
|
+
case 'ArrowDown':
|
|
2236
|
+
e.preventDefault();
|
|
2237
|
+
this.scheduleSelectedIndex = Math.min(
|
|
2238
|
+
this.scheduleSelectedIndex + 1,
|
|
2239
|
+
this.scheduleSuggestions.length - 1
|
|
2240
|
+
);
|
|
2241
|
+
this.renderScheduleSuggestions();
|
|
2242
|
+
break;
|
|
2243
|
+
case 'ArrowUp':
|
|
2244
|
+
e.preventDefault();
|
|
2245
|
+
this.scheduleSelectedIndex = Math.max(this.scheduleSelectedIndex - 1, -1);
|
|
2246
|
+
this.renderScheduleSuggestions();
|
|
2247
|
+
break;
|
|
2248
|
+
case 'Tab':
|
|
2249
|
+
if (this.scheduleSelectedIndex >= 0) {
|
|
2250
|
+
e.preventDefault();
|
|
2251
|
+
this.selectScheduleSuggestion(this.scheduleSelectedIndex);
|
|
2252
|
+
} else if (this.scheduleSuggestions.length > 0) {
|
|
2253
|
+
e.preventDefault();
|
|
2254
|
+
this.selectScheduleSuggestion(0);
|
|
2255
|
+
}
|
|
2256
|
+
break;
|
|
2257
|
+
case 'Enter':
|
|
2258
|
+
if (this.scheduleSelectedIndex >= 0) {
|
|
2259
|
+
e.preventDefault();
|
|
2260
|
+
this.selectScheduleSuggestion(this.scheduleSelectedIndex);
|
|
2261
|
+
}
|
|
2262
|
+
break;
|
|
2263
|
+
case 'Escape':
|
|
2264
|
+
this.hideScheduleSuggestions();
|
|
2265
|
+
break;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
// Utility: format run timestamp for display
|
|
2270
|
+
formatRunTimestamp(ts) {
|
|
2271
|
+
if (!ts) return 'Unknown';
|
|
2272
|
+
// timestamps may have colons replaced with dashes
|
|
2273
|
+
const normalized = ts.replace(/T(\d{2})-(\d{2})-(\d{2})/, 'T$1:$2:$3');
|
|
2274
|
+
try {
|
|
2275
|
+
const date = new Date(normalized);
|
|
2276
|
+
if (isNaN(date.getTime())) return ts;
|
|
2277
|
+
return date.toLocaleString(undefined, {
|
|
2278
|
+
month: 'short', day: 'numeric',
|
|
2279
|
+
hour: '2-digit', minute: '2-digit'
|
|
2280
|
+
});
|
|
2281
|
+
} catch {
|
|
2282
|
+
return ts;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// Utility: format duration in ms to human readable
|
|
2287
|
+
formatDuration(ms) {
|
|
2288
|
+
if (ms < 1000) return `${ms}ms`;
|
|
2289
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
2290
|
+
const mins = Math.floor(ms / 60000);
|
|
2291
|
+
const secs = Math.floor((ms % 60000) / 1000);
|
|
2292
|
+
return `${mins}m ${secs}s`;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// Utility: escape HTML
|
|
2296
|
+
escapeHtml(str) {
|
|
2297
|
+
const div = document.createElement('div');
|
|
2298
|
+
div.textContent = str;
|
|
2299
|
+
return div.innerHTML;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// Helper to get display name for a session
|
|
2303
|
+
getDisplayName(session) {
|
|
2304
|
+
const getFolderName = (cwd) => {
|
|
2305
|
+
const parts = cwd.split('/').filter(Boolean);
|
|
2306
|
+
return parts[parts.length - 1] || cwd;
|
|
2307
|
+
};
|
|
2308
|
+
|
|
2309
|
+
const folderCounts = new Map();
|
|
2310
|
+
for (const s of this.sessions) {
|
|
2311
|
+
const dirName = getFolderName(s.cwd);
|
|
2312
|
+
folderCounts.set(dirName, (folderCounts.get(dirName) || 0) + 1);
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const dirName = getFolderName(session.cwd);
|
|
2316
|
+
if (folderCounts.get(dirName) > 1) {
|
|
2317
|
+
return `${session.id.slice(0, 3)} ${dirName}`;
|
|
2318
|
+
}
|
|
2319
|
+
return dirName;
|
|
2320
|
+
}
|
|
496
2321
|
}
|
|
497
2322
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
2323
|
+
// Initialize app when DOM is ready
|
|
2324
|
+
if (document.readyState === 'loading') {
|
|
2325
|
+
document.addEventListener('DOMContentLoaded', () => new ClaudeRemote());
|
|
2326
|
+
} else {
|
|
2327
|
+
new ClaudeRemote();
|
|
2328
|
+
}
|