glad-web 1.0.28 → 1.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/claude/cli-usage.js +95 -0
- package/lib/claude/structured-session.js +223 -51
- package/lib/claude/transcript-repository.js +216 -0
- package/lib/codex/image-store.js +175 -0
- package/lib/codex/structured-session.js +18 -4
- package/lib/commands/web.js +52 -234
- package/lib/server/routes/providers.js +103 -0
- package/lib/server/routes/schedules.js +54 -0
- package/lib/server/routes/workspace.js +77 -0
- package/lib/session/session-manager.js +111 -340
- package/lib/web/claude.js +1074 -0
- package/lib/web/codex.js +475 -0
- package/lib/web/composer.js +230 -0
- package/lib/web/core.js +327 -0
- package/lib/web/git.js +533 -0
- package/lib/web/index.html +45 -3610
- package/lib/web/schedules.js +245 -0
- package/lib/web/session.js +351 -0
- package/lib/web/shell.js +56 -0
- package/lib/web/styles.css +377 -0
- package/lib/web/terminal-scroll.js +81 -0
- package/lib/web/timed-inputs.js +223 -0
- package/lib/workspace/service.js +3 -2
- package/package.json +10 -5
- package/scripts/check-syntax.js +26 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
const inputEl = document.getElementById('cmd-input');
|
|
2
|
+
const imageFileInput = document.getElementById('image-file-input');
|
|
3
|
+
const attachmentStrip = document.getElementById('attachment-strip');
|
|
4
|
+
let selectedImageAttachments = [];
|
|
5
|
+
let keepTerminalBottomForNextInput = false;
|
|
6
|
+
|
|
7
|
+
function isStructuredImageAttachmentAvailable() {
|
|
8
|
+
return isClaudeSession() || (isCodexSession() && codexState.presentation === 'structured');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function syncComposerButtonState() {
|
|
12
|
+
const menuOpen = document.getElementById('composer-menu').classList.contains('active');
|
|
13
|
+
const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
|
|
14
|
+
document.getElementById('timer-btn').classList.toggle('active', menuOpen || timerOpen);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderImageAttachments() {
|
|
18
|
+
attachmentStrip.innerHTML = selectedImageAttachments.map(item => (
|
|
19
|
+
`<div class="attachment-chip${item.uploading ? ' uploading' : ''}"><span aria-hidden="true">▧</span><span class="attachment-chip-content"><span class="attachment-chip-name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</span>${item.uploading ? `<span class="attachment-progress${item.progressKnown ? '' : ' estimated'}"><span style="width:${Math.max(0, Math.min(100, item.progress || 0))}%"></span></span><span class="attachment-status">${escapeHtml(item.status || (item.progressKnown ? `Uploading ${Math.round(item.progress || 0)}%` : 'Uploading original image…'))}</span>` : ''}</span><button class="attachment-remove" type="button" title="Remove image" aria-label="Remove ${escapeHtml(item.name)}" onclick="removeImageAttachment('${item.id}')">×</button></div>`
|
|
20
|
+
)).join('');
|
|
21
|
+
attachmentStrip.classList.toggle('active', selectedImageAttachments.length > 0);
|
|
22
|
+
updateTerminalControlsHeight();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
window.removeImageAttachment = async function(attachmentId) {
|
|
26
|
+
const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
|
|
27
|
+
selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
|
|
28
|
+
renderImageAttachments();
|
|
29
|
+
if (!attachment) return;
|
|
30
|
+
clearInterval(attachment.indicatorTimer);
|
|
31
|
+
attachment.abortUpload?.();
|
|
32
|
+
if (attachment.uploading) return;
|
|
33
|
+
try {
|
|
34
|
+
await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
35
|
+
} catch (_) {
|
|
36
|
+
// The server also removes all attachments when the session ends.
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function clearImageAttachments() {
|
|
41
|
+
const pending = selectedImageAttachments;
|
|
42
|
+
selectedImageAttachments = [];
|
|
43
|
+
renderImageAttachments();
|
|
44
|
+
for (const item of pending) item.abortUpload?.();
|
|
45
|
+
await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
|
|
46
|
+
`/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
|
|
47
|
+
{ method: 'DELETE' }
|
|
48
|
+
).catch(() => null)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const IMAGE_UPLOAD_CHUNK_BYTES = 512 * 1024;
|
|
52
|
+
|
|
53
|
+
function uploadImageInChunks(sessionId, file, onProgress) {
|
|
54
|
+
let xhr = null;
|
|
55
|
+
let cancelled = false;
|
|
56
|
+
const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
57
|
+
const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
|
|
58
|
+
return {
|
|
59
|
+
abort: () => {
|
|
60
|
+
cancelled = true;
|
|
61
|
+
xhr?.abort();
|
|
62
|
+
void fetch(`/api/sessions/${sessionId}/attachments/images/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
|
|
63
|
+
},
|
|
64
|
+
promise: (async () => {
|
|
65
|
+
for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
|
|
66
|
+
if (cancelled) throw new Error('Image upload cancelled');
|
|
67
|
+
const start = chunkIndex * IMAGE_UPLOAD_CHUNK_BYTES;
|
|
68
|
+
const chunk = file.slice(start, Math.min(file.size, start + IMAGE_UPLOAD_CHUNK_BYTES));
|
|
69
|
+
const result = await new Promise((resolve, reject) => {
|
|
70
|
+
xhr = new XMLHttpRequest();
|
|
71
|
+
xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
|
|
72
|
+
xhr.timeout = 60_000;
|
|
73
|
+
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
|
74
|
+
xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
|
|
75
|
+
xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
|
|
76
|
+
xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
|
|
77
|
+
xhr.onerror = () => reject(new Error('Network error while uploading image'));
|
|
78
|
+
xhr.ontimeout = () => reject(new Error(`Image upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
|
|
79
|
+
xhr.onabort = () => reject(new Error('Image upload cancelled'));
|
|
80
|
+
xhr.onload = () => {
|
|
81
|
+
let data = {};
|
|
82
|
+
try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
|
|
83
|
+
if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
|
|
84
|
+
reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
resolve(data);
|
|
88
|
+
};
|
|
89
|
+
xhr.send(chunk);
|
|
90
|
+
});
|
|
91
|
+
const confirmedBytes = Math.min(file.size, start + chunk.size);
|
|
92
|
+
onProgress(Math.round((confirmedBytes / file.size) * 100), chunkIndex + 1, chunkTotal);
|
|
93
|
+
if (result.complete) return result.attachment;
|
|
94
|
+
}
|
|
95
|
+
throw new Error('Image upload did not complete');
|
|
96
|
+
})()
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function uploadImageFiles(files) {
|
|
101
|
+
if (!isStructuredImageAttachmentAvailable()) {
|
|
102
|
+
alert('Image attachments are available only in structured chat mode.');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const remaining = 5 - selectedImageAttachments.length;
|
|
106
|
+
const batch = Array.from(files).slice(0, remaining);
|
|
107
|
+
if (files.length > remaining) alert('You can attach up to 5 images at a time.');
|
|
108
|
+
for (const file of batch) {
|
|
109
|
+
if (file.size > 50 * 1024 * 1024) {
|
|
110
|
+
alert(`${file.name} is larger than 50 MB.`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
|
|
114
|
+
const pending = {
|
|
115
|
+
id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
|
|
116
|
+
name: file.name || 'image',
|
|
117
|
+
sessionId: activeSessionId,
|
|
118
|
+
uploading: true,
|
|
119
|
+
progress: 0,
|
|
120
|
+
progressKnown: true,
|
|
121
|
+
status: '0%',
|
|
122
|
+
abortUpload: null
|
|
123
|
+
};
|
|
124
|
+
selectedImageAttachments.push(pending);
|
|
125
|
+
renderImageAttachments();
|
|
126
|
+
try {
|
|
127
|
+
const upload = uploadImageInChunks(activeSessionId, file, progress => {
|
|
128
|
+
pending.progress = progress;
|
|
129
|
+
pending.status = `${progress}%`;
|
|
130
|
+
renderImageAttachments();
|
|
131
|
+
});
|
|
132
|
+
pending.abortUpload = upload.abort;
|
|
133
|
+
const attachment = await upload.promise;
|
|
134
|
+
const index = selectedImageAttachments.indexOf(pending);
|
|
135
|
+
if (index < 0) {
|
|
136
|
+
await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
|
|
140
|
+
renderImageAttachments();
|
|
141
|
+
} catch (e) {
|
|
142
|
+
selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
|
|
143
|
+
renderImageAttachments();
|
|
144
|
+
if (e.message === 'Image upload cancelled') continue;
|
|
145
|
+
alert(`Could not add ${file.name}: ${e.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function closeComposerMenu() {
|
|
151
|
+
document.getElementById('composer-menu').classList.remove('active');
|
|
152
|
+
syncComposerButtonState();
|
|
153
|
+
updateTerminalControlsHeight();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function openTimedSendPanel() {
|
|
157
|
+
closeComposerMenu();
|
|
158
|
+
if (isClaudeSession()) {
|
|
159
|
+
closeClaudePicker();
|
|
160
|
+
claudeResumePanelOpen = false;
|
|
161
|
+
claudeForkPanelOpen = false;
|
|
162
|
+
document.getElementById('claude-resume-panel').classList.remove('active');
|
|
163
|
+
document.getElementById('claude-fork-panel').classList.remove('active');
|
|
164
|
+
}
|
|
165
|
+
initTimedDelaySelectors();
|
|
166
|
+
resetTimedEditor({ keepInput: true });
|
|
167
|
+
document.getElementById('timed-send-panel').classList.add('active');
|
|
168
|
+
updateTimedSendPreview();
|
|
169
|
+
loadTimedInputs();
|
|
170
|
+
syncComposerButtonState();
|
|
171
|
+
updateTerminalControlsHeight();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function markInputEditStart() {
|
|
175
|
+
keepTerminalBottomForNextInput = keepTerminalBottomForNextInput || isTerminalAtBottom();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
inputEl.addEventListener('beforeinput', markInputEditStart);
|
|
179
|
+
inputEl.addEventListener('input', function() {
|
|
180
|
+
const keepAtBottom = keepTerminalBottomForNextInput || isTerminalAtBottom();
|
|
181
|
+
keepTerminalBottomForNextInput = false;
|
|
182
|
+
this.style.height = 'auto';
|
|
183
|
+
this.style.height = Math.min(this.scrollHeight, 150) + 'px';
|
|
184
|
+
if (keepAtBottom) restoreTerminalBottomSoon();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
function performSend() {
|
|
188
|
+
const val = inputEl.value;
|
|
189
|
+
const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
|
|
190
|
+
if (selectedImageAttachments.some(item => item.uploading)) {
|
|
191
|
+
alert('Wait for image uploads to finish before sending.');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if ((val || readyImageAttachments.length) && isClaudeSession()) {
|
|
195
|
+
if (currentSocket && currentSocket.readyState === 1) {
|
|
196
|
+
currentSocket.send(JSON.stringify({
|
|
197
|
+
type: 'claude-input',
|
|
198
|
+
text: val,
|
|
199
|
+
attachmentIds: readyImageAttachments.map(item => item.id)
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
inputEl.value = '';
|
|
203
|
+
inputEl.style.height = '38px';
|
|
204
|
+
selectedImageAttachments = [];
|
|
205
|
+
renderImageAttachments();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
|
|
209
|
+
if (currentSocket && currentSocket.readyState === 1) {
|
|
210
|
+
currentSocket.send(JSON.stringify({
|
|
211
|
+
type: 'codex-input',
|
|
212
|
+
text: val,
|
|
213
|
+
attachmentIds: readyImageAttachments.map(item => item.id)
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
inputEl.value = '';
|
|
217
|
+
inputEl.style.height = '38px';
|
|
218
|
+
selectedImageAttachments = [];
|
|
219
|
+
renderImageAttachments();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (val) {
|
|
223
|
+
const formattedVal = val.replace(/\n/g, '\r');
|
|
224
|
+
sendWS(formattedVal);
|
|
225
|
+
inputEl.value = '';
|
|
226
|
+
inputEl.style.height = '38px';
|
|
227
|
+
restoreTerminalBottomSoon();
|
|
228
|
+
setTimeout(() => { sendWS('\r'); }, 1000);
|
|
229
|
+
}
|
|
230
|
+
}
|
package/lib/web/core.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
let currentSocket = null, term = null, fitAddon = null, activeSessionId = null, activeToolKey = null;
|
|
2
|
+
let appConfig = null;
|
|
3
|
+
let sessionPollTimer = null;
|
|
4
|
+
let lobbyTab = 'sessions';
|
|
5
|
+
let scheduleTools = [];
|
|
6
|
+
let editingScheduleId = null;
|
|
7
|
+
let editingSteps = [];
|
|
8
|
+
let selectedWeekdays = [1, 2, 3, 4, 5];
|
|
9
|
+
let timedSendRefreshTimer = null;
|
|
10
|
+
let timedTagTimer = null;
|
|
11
|
+
let editingTimedInputId = null;
|
|
12
|
+
let claudeMessages = [];
|
|
13
|
+
let claudePendingPermissions = [];
|
|
14
|
+
let claudeStatus = 'idle';
|
|
15
|
+
let claudeRuntimeConfig = null;
|
|
16
|
+
let claudePickerOpen = null;
|
|
17
|
+
let claudeUsagePending = false;
|
|
18
|
+
let claudeContextPending = false;
|
|
19
|
+
let claudeState = {
|
|
20
|
+
permissionMode: 'default',
|
|
21
|
+
model: 'default',
|
|
22
|
+
effort: 'medium',
|
|
23
|
+
claudeSessionId: null,
|
|
24
|
+
resumeSessionId: null,
|
|
25
|
+
canAbort: false,
|
|
26
|
+
pendingPermissionCount: 0
|
|
27
|
+
};
|
|
28
|
+
let claudeResumePanelOpen = false;
|
|
29
|
+
let claudeForkPanelOpen = false;
|
|
30
|
+
let claudeResumeItemsLoaded = false;
|
|
31
|
+
let claudeRenderFrame = null;
|
|
32
|
+
let claudeApprovalJumpIndex = 0;
|
|
33
|
+
let codexMessages = [];
|
|
34
|
+
let codexPendingPermissions = [];
|
|
35
|
+
let codexState = { permissionMode: 'default', sandboxMode: 'default', effectivePermissionMode: null, effectiveSandboxMode: null, model: null, effort: null, status: 'idle', threadId: null, presentation: 'structured', models: [], canAbort: false, canSwitchToTerminal: false, canSwitchToStructured: false };
|
|
36
|
+
let codexModelPanelOpen = false;
|
|
37
|
+
let codexModelCandidate = null;
|
|
38
|
+
let codexResumePanelOpen = false;
|
|
39
|
+
let codexForkPanelOpen = false;
|
|
40
|
+
let codexRenderFrame = null;
|
|
41
|
+
let codexApprovalJumpIndex = 0;
|
|
42
|
+
const modifiers = { ctrl: false };
|
|
43
|
+
|
|
44
|
+
function log(msg) {
|
|
45
|
+
const el = document.getElementById('debug-log');
|
|
46
|
+
const entry = document.createElement('div');
|
|
47
|
+
entry.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
|
|
48
|
+
el.appendChild(entry);
|
|
49
|
+
console.log(msg);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
57
|
+
clearTimeout(id);
|
|
58
|
+
return response;
|
|
59
|
+
} catch (e) {
|
|
60
|
+
clearTimeout(id);
|
|
61
|
+
if (e && e.name === 'AbortError') {
|
|
62
|
+
throw new Error(`Request timed out after ${Math.round(timeout / 1000)}s`);
|
|
63
|
+
}
|
|
64
|
+
throw e;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function escapeHtml(value = '') {
|
|
69
|
+
return String(value)
|
|
70
|
+
.replace(/&/g, '&')
|
|
71
|
+
.replace(/</g, '<')
|
|
72
|
+
.replace(/>/g, '>')
|
|
73
|
+
.replace(/"/g, '"')
|
|
74
|
+
.replace(/'/g, ''');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function encodePathValue(path = '') {
|
|
78
|
+
return encodeURIComponent(path).replace(/'/g, '%27');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function decodePathValue(path = '') {
|
|
82
|
+
return decodeURIComponent(path);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function copyTextToClipboard(text) {
|
|
86
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
87
|
+
await navigator.clipboard.writeText(text);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const el = document.createElement('textarea');
|
|
91
|
+
el.value = text;
|
|
92
|
+
el.setAttribute('readonly', '');
|
|
93
|
+
el.style.position = 'fixed';
|
|
94
|
+
el.style.opacity = '0';
|
|
95
|
+
document.body.appendChild(el);
|
|
96
|
+
el.select();
|
|
97
|
+
document.execCommand('copy');
|
|
98
|
+
document.body.removeChild(el);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function copySessionDirectory(directory, event) {
|
|
102
|
+
event.stopPropagation();
|
|
103
|
+
const btn = event.currentTarget;
|
|
104
|
+
try {
|
|
105
|
+
await copyTextToClipboard(directory);
|
|
106
|
+
const oldTitle = btn.title;
|
|
107
|
+
btn.title = 'Copied';
|
|
108
|
+
btn.style.color = '#fff';
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
btn.title = oldTitle || 'Copy directory';
|
|
111
|
+
btn.style.color = '';
|
|
112
|
+
}, 1200);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
alert('Copy failed');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatWeekdays(days = []) {
|
|
119
|
+
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
120
|
+
return days.map(day => labels[day]).filter(Boolean).join(', ') || 'No days';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatDateTime(value) {
|
|
124
|
+
return value ? new Date(value).toLocaleString() : 'Not scheduled';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function switchLobbyTab(tab) {
|
|
128
|
+
lobbyTab = tab;
|
|
129
|
+
document.getElementById('lobby-tab-sessions').classList.toggle('active', tab === 'sessions');
|
|
130
|
+
document.getElementById('lobby-tab-schedules').classList.toggle('active', tab === 'schedules');
|
|
131
|
+
document.getElementById('sessions-list').style.display = tab === 'sessions' ? '' : 'none';
|
|
132
|
+
document.getElementById('schedules-list').style.display = tab === 'schedules' ? '' : 'none';
|
|
133
|
+
if (tab === 'sessions') refreshSessionsNow();
|
|
134
|
+
if (tab === 'schedules') loadSchedules();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function loadSchedules() {
|
|
138
|
+
const list = document.getElementById('schedules-list');
|
|
139
|
+
list.innerHTML = '<p style="color:#888; text-align:center; margin-top:50px;">Loading schedules...</p>';
|
|
140
|
+
try {
|
|
141
|
+
await ensureScheduleTools().catch(() => {});
|
|
142
|
+
const res = await fetchWithTimeout('/api/schedules');
|
|
143
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
144
|
+
const schedules = await res.json();
|
|
145
|
+
if (!schedules.length) {
|
|
146
|
+
list.innerHTML = '<p style="color:#888; text-align:center; margin-top:50px;">No scheduled tasks</p>';
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let html = '';
|
|
151
|
+
schedules.forEach(job => {
|
|
152
|
+
const encodedId = encodePathValue(job.id);
|
|
153
|
+
const tool = scheduleTools.find(t => t.key === job.target.toolKey);
|
|
154
|
+
const statusColor = job.lastRunStatus === 'failed' ? '#ff6b61' : job.running ? '#5ac8fa' : 'var(--text-dim)';
|
|
155
|
+
html += `<div class="schedule-card">
|
|
156
|
+
<div class="schedule-row">
|
|
157
|
+
<div style="flex:1; min-width:0;">
|
|
158
|
+
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
|
|
159
|
+
<strong>${escapeHtml(job.name)}</strong>
|
|
160
|
+
<span style="font-size:11px; color:${job.enabled ? '#34c759' : '#8e8e93'}; border:1px solid currentColor; border-radius:10px; padding:1px 7px;">${job.enabled ? 'ON' : 'OFF'}</span>
|
|
161
|
+
</div>
|
|
162
|
+
<div class="schedule-meta">
|
|
163
|
+
${escapeHtml(job.schedule.time)} | ${escapeHtml(formatWeekdays(job.schedule.weekdays))}<br>
|
|
164
|
+
${escapeHtml(tool ? tool.displayName : job.target.toolKey)} | ${escapeHtml(job.target.workingDirectory || 'Default directory')}<br>
|
|
165
|
+
Next: ${escapeHtml(formatDateTime(job.nextRunAt))}<br>
|
|
166
|
+
<span style="color:${statusColor};">Last: ${escapeHtml(job.lastRunStatus || 'idle')}${job.lastRunMessage ? ' - ' + escapeHtml(job.lastRunMessage) : ''}</span>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="schedule-actions">
|
|
170
|
+
<button class="small-btn primary" onclick="simulateSchedule(decodePathValue('${encodedId}'))">Test</button>
|
|
171
|
+
<button class="small-btn" onclick="editSchedule(decodePathValue('${encodedId}'))">Edit</button>
|
|
172
|
+
<button class="small-btn" onclick="duplicateSchedule(decodePathValue('${encodedId}'))">Copy</button>
|
|
173
|
+
<button class="small-btn" onclick="toggleSchedule(decodePathValue('${encodedId}'), ${job.enabled ? 'false' : 'true'})">${job.enabled ? 'Disable' : 'Enable'}</button>
|
|
174
|
+
<button class="small-btn danger" onclick="deleteSchedule(decodePathValue('${encodedId}'))">Delete</button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>`;
|
|
178
|
+
});
|
|
179
|
+
list.innerHTML = html;
|
|
180
|
+
} catch (e) {
|
|
181
|
+
list.innerHTML = `<div style="color:#ff3b30; text-align:center; margin-top:50px;"><p>Failed to load: ${escapeHtml(e.message)}</p><button class="btn-retry" onclick="loadSchedules()">Retry</button></div>`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function loadSessions() {
|
|
186
|
+
log('Loading sessions...');
|
|
187
|
+
const list = document.getElementById('sessions-list');
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetchWithTimeout('/api/sessions');
|
|
190
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
191
|
+
const sessions = await res.json();
|
|
192
|
+
if (!sessions || sessions.length === 0) {
|
|
193
|
+
list.innerHTML = '<p style="color:#888; text-align:center; margin-top:50px;">No active sessions</p>';
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let html = '';
|
|
198
|
+
sessions.forEach(s => {
|
|
199
|
+
const workingDirectory = s.workingDirectory || 'Unknown directory';
|
|
200
|
+
const encodedName = encodePathValue(s.name);
|
|
201
|
+
const encodedDir = encodePathValue(workingDirectory);
|
|
202
|
+
const timedInputCount = Number(s.timedInputCount) || 0;
|
|
203
|
+
const timerBadge = timedInputCount > 0
|
|
204
|
+
? `<span class="timer-count-badge" title="${timedInputCount} scheduled timer${timedInputCount > 1 ? 's' : ''}">
|
|
205
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
|
|
206
|
+
${timedInputCount}
|
|
207
|
+
</span>`
|
|
208
|
+
: '';
|
|
209
|
+
html += `<div class="session-card">
|
|
210
|
+
<div class="session-info">
|
|
211
|
+
<h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button class="icon-btn" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
|
|
212
|
+
<p>${escapeHtml(s.tool)}</p>
|
|
213
|
+
<p>${new Date(s.startTime).toLocaleTimeString()}</p>
|
|
214
|
+
<div class="session-dir-row">
|
|
215
|
+
<button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
|
|
216
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
217
|
+
</button>
|
|
218
|
+
<p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<div class="session-actions">
|
|
222
|
+
<button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
|
|
223
|
+
<button class="icon-btn btn-delete" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><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"></path></svg></button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>`;
|
|
226
|
+
});
|
|
227
|
+
list.innerHTML = html;
|
|
228
|
+
} catch (e) {
|
|
229
|
+
list.innerHTML = `<div style="color:#ff3b30; text-align:center; margin-top:50px;"><p>Failed to load: ${e.message}</p><button class="btn-retry" onclick="loadSessions()">Retry</button></div>`;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function renameSession(id, oldName, e) {
|
|
234
|
+
e.stopPropagation();
|
|
235
|
+
const newName = prompt('Rename session', oldName);
|
|
236
|
+
if (newName && newName !== oldName) {
|
|
237
|
+
try {
|
|
238
|
+
await fetchWithTimeout('/api/sessions/' + id, {
|
|
239
|
+
method: 'PATCH',
|
|
240
|
+
headers: { 'Content-Type': 'application/json' },
|
|
241
|
+
body: JSON.stringify({ name: newName })
|
|
242
|
+
});
|
|
243
|
+
refreshSessionsNow();
|
|
244
|
+
} catch (e) { alert('Rename failed'); }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function showToolModal() {
|
|
249
|
+
document.getElementById('modal-overlay').style.display = 'flex';
|
|
250
|
+
const list = document.getElementById('tools-list');
|
|
251
|
+
loadAppConfig();
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetchWithTimeout('/api/tools');
|
|
254
|
+
const tools = await res.json();
|
|
255
|
+
|
|
256
|
+
let html = '';
|
|
257
|
+
tools.forEach(t => {
|
|
258
|
+
const versionLabel = t.version && t.version !== 'unknown' ? `v${t.version}` : 'version unknown';
|
|
259
|
+
html += `<div class="tool-item" onclick='createSession(${JSON.stringify(t.key)}, ${JSON.stringify(t.displayName)})'>
|
|
260
|
+
<div class="tool-icon">${t.displayName[0]}</div>
|
|
261
|
+
<div>
|
|
262
|
+
<div style="font-weight:600">${t.displayName}</div>
|
|
263
|
+
<div style="font-size:12px; color:#888">${versionLabel}</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>`;
|
|
266
|
+
});
|
|
267
|
+
list.innerHTML = html;
|
|
268
|
+
} catch (e) {
|
|
269
|
+
list.innerHTML = `<p style="color:#ff3b30">Detection failed: ${e.message}</p>`;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function loadAppConfig() {
|
|
274
|
+
if (appConfig) {
|
|
275
|
+
document.getElementById('default-cwd').textContent = appConfig.defaultWorkingDirectory || 'Current folder';
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const res = await fetchWithTimeout('/api/config');
|
|
280
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
281
|
+
appConfig = await res.json();
|
|
282
|
+
document.getElementById('default-cwd').textContent = appConfig.defaultWorkingDirectory || 'Current folder';
|
|
283
|
+
} catch (e) {
|
|
284
|
+
document.getElementById('default-cwd').textContent = 'Current folder';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function closeToolModal(e) {
|
|
289
|
+
if (e.target.id === 'modal-overlay') document.getElementById('modal-overlay').style.display = 'none';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isLobbyVisible() {
|
|
293
|
+
return document.getElementById('lobby-view').classList.contains('active');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function scheduleSessionPolling() {
|
|
297
|
+
clearTimeout(sessionPollTimer);
|
|
298
|
+
sessionPollTimer = null;
|
|
299
|
+
if (!isLobbyVisible()) return;
|
|
300
|
+
|
|
301
|
+
const delay = document.hidden ? 30000 : 10000;
|
|
302
|
+
sessionPollTimer = setTimeout(async () => {
|
|
303
|
+
await loadSessions();
|
|
304
|
+
scheduleSessionPolling();
|
|
305
|
+
}, delay);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function refreshSessionsNow() {
|
|
309
|
+
clearTimeout(sessionPollTimer);
|
|
310
|
+
sessionPollTimer = null;
|
|
311
|
+
if (!isLobbyVisible()) return;
|
|
312
|
+
if (lobbyTab === 'schedules') {
|
|
313
|
+
await loadSchedules();
|
|
314
|
+
scheduleSessionPolling();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
await loadSessions();
|
|
318
|
+
scheduleSessionPolling();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function markCompletionRead(id) {
|
|
322
|
+
try {
|
|
323
|
+
await fetchWithTimeout('/api/sessions/' + id + '/completion/read', { method: 'POST' });
|
|
324
|
+
} catch (e) {
|
|
325
|
+
log('Failed to mark completion read: ' + e.message);
|
|
326
|
+
}
|
|
327
|
+
}
|