glad-web 1.0.44 → 1.0.46
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/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/lib/claude/structured-session.js +10 -5
- package/lib/codex/image-store.js +1 -2
- package/lib/codex/structured-session.js +39 -76
- package/lib/commands/web.js +64 -9
- package/lib/config/manager.js +19 -0
- package/lib/server/routes/providers.js +0 -12
- package/lib/server/routes/skillhub.js +104 -0
- package/lib/session/file-attachment-store.js +168 -0
- package/lib/session/session-manager.js +143 -60
- package/lib/skillhub/client.js +121 -0
- package/lib/skillhub/settings-store.js +168 -0
- package/lib/skillhub/skill-installer.js +320 -0
- package/lib/web/bootstrap.js +34 -0
- package/lib/web/claude.js +44 -16
- package/lib/web/codex.js +51 -22
- package/lib/web/composer.js +293 -28
- package/lib/web/core.js +56 -38
- package/lib/web/git.js +53 -51
- package/lib/web/gitgraph.js +8 -8
- package/lib/web/index.html +61 -26
- package/lib/web/layout.js +4 -7
- package/lib/web/notifications.js +1 -0
- package/lib/web/session.js +7 -21
- package/lib/web/shell.js +17 -2
- package/lib/web/skillhub.js +197 -0
- package/lib/web/styles.css +138 -16
- package/lib/web/timed-inputs.js +6 -6
- package/package.json +6 -3
package/lib/web/composer.js
CHANGED
|
@@ -1,24 +1,143 @@
|
|
|
1
1
|
const inputEl = document.getElementById('cmd-input');
|
|
2
|
-
const
|
|
2
|
+
const attachmentFileInput = document.getElementById('attachment-file-input');
|
|
3
3
|
const attachmentStrip = document.getElementById('attachment-strip');
|
|
4
4
|
let selectedImageAttachments = [];
|
|
5
|
+
let selectedFileAttachments = [];
|
|
5
6
|
let keepTerminalBottomForNextInput = false;
|
|
7
|
+
const composerActionTooltip = document.getElementById('composer-action-tooltip');
|
|
8
|
+
let composerActionTooltipTimer = null;
|
|
9
|
+
let composerTouchGesture = null;
|
|
10
|
+
let suppressTouchFocusUntil = 0;
|
|
11
|
+
let composerSendPending = false;
|
|
12
|
+
|
|
13
|
+
function composerExecutionInProgress() {
|
|
14
|
+
if (composerSendPending) return true;
|
|
15
|
+
if (isClaudeSession()) return claudeStatus === 'thinking';
|
|
16
|
+
if (isCodexSession()) return !codexReadyForInput();
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function syncComposerSendState({ acknowledgeProviderState = false } = {}) {
|
|
21
|
+
if (acknowledgeProviderState) composerSendPending = false;
|
|
22
|
+
const sendButton = document.getElementById('send-btn');
|
|
23
|
+
const executing = composerExecutionInProgress();
|
|
24
|
+
sendButton.disabled = executing;
|
|
25
|
+
sendButton.title = executing ? 'Wait for the current run to finish' : 'Send';
|
|
26
|
+
sendButton.setAttribute('aria-label', executing ? 'Send disabled while running' : 'Send');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function markComposerSendPending() {
|
|
30
|
+
composerSendPending = true;
|
|
31
|
+
syncComposerSendState();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resetComposerSendState() {
|
|
35
|
+
composerSendPending = false;
|
|
36
|
+
syncComposerSendState();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function composerActionControl(target) {
|
|
40
|
+
const control = target instanceof Element ? target.closest('button, label') : null;
|
|
41
|
+
if (!control || !document.getElementById('terminal-controls').contains(control)) return null;
|
|
42
|
+
return control.querySelector('.action-icon') ? control : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function composerActionName(control) {
|
|
46
|
+
return control?.querySelector('.action-label')?.textContent?.trim()
|
|
47
|
+
|| control?.getAttribute('aria-label')
|
|
48
|
+
|| control?.getAttribute('title')
|
|
49
|
+
|| '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hideComposerActionTooltip() {
|
|
53
|
+
clearTimeout(composerActionTooltipTimer);
|
|
54
|
+
composerActionTooltipTimer = null;
|
|
55
|
+
composerActionTooltip.classList.remove('visible');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function showComposerActionTooltip(control, autoHide = false) {
|
|
59
|
+
const label = composerActionName(control);
|
|
60
|
+
if (!label) return;
|
|
61
|
+
clearTimeout(composerActionTooltipTimer);
|
|
62
|
+
composerActionTooltip.textContent = label;
|
|
63
|
+
composerActionTooltip.classList.add('visible');
|
|
64
|
+
const controlBox = control.getBoundingClientRect();
|
|
65
|
+
const tooltipBox = composerActionTooltip.getBoundingClientRect();
|
|
66
|
+
const left = Math.max(8, Math.min(window.innerWidth - tooltipBox.width - 8,
|
|
67
|
+
controlBox.left + controlBox.width / 2 - tooltipBox.width / 2));
|
|
68
|
+
const above = controlBox.top - tooltipBox.height - 8;
|
|
69
|
+
composerActionTooltip.style.left = `${left}px`;
|
|
70
|
+
composerActionTooltip.style.top = `${above >= 8 ? above : controlBox.bottom + 8}px`;
|
|
71
|
+
if (autoHide) composerActionTooltipTimer = setTimeout(hideComposerActionTooltip, 1400);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const terminalControls = document.getElementById('terminal-controls');
|
|
75
|
+
terminalControls.addEventListener('focusin', event => {
|
|
76
|
+
if (performance.now() < suppressTouchFocusUntil) return;
|
|
77
|
+
const control = composerActionControl(event.target);
|
|
78
|
+
if (control) showComposerActionTooltip(control);
|
|
79
|
+
});
|
|
80
|
+
terminalControls.addEventListener('focusout', hideComposerActionTooltip);
|
|
81
|
+
terminalControls.addEventListener('pointerover', event => {
|
|
82
|
+
const control = composerActionControl(event.target);
|
|
83
|
+
if (control && event.pointerType !== 'touch') showComposerActionTooltip(control);
|
|
84
|
+
});
|
|
85
|
+
terminalControls.addEventListener('pointerout', event => {
|
|
86
|
+
const control = composerActionControl(event.target);
|
|
87
|
+
if (control && !control.contains(event.relatedTarget)) hideComposerActionTooltip();
|
|
88
|
+
});
|
|
89
|
+
terminalControls.addEventListener('pointerdown', event => {
|
|
90
|
+
const control = composerActionControl(event.target);
|
|
91
|
+
if (control && event.pointerType === 'touch') {
|
|
92
|
+
composerTouchGesture = { pointerId: event.pointerId, control, x: event.clientX, y: event.clientY, moved: false };
|
|
93
|
+
suppressTouchFocusUntil = performance.now() + 800;
|
|
94
|
+
hideComposerActionTooltip();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
terminalControls.addEventListener('pointermove', event => {
|
|
98
|
+
if (!composerTouchGesture || event.pointerId !== composerTouchGesture.pointerId) return;
|
|
99
|
+
if (Math.hypot(event.clientX - composerTouchGesture.x, event.clientY - composerTouchGesture.y) > 8) {
|
|
100
|
+
composerTouchGesture.moved = true;
|
|
101
|
+
hideComposerActionTooltip();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
terminalControls.addEventListener('pointerup', event => {
|
|
105
|
+
if (!composerTouchGesture || event.pointerId !== composerTouchGesture.pointerId) return;
|
|
106
|
+
const gesture = composerTouchGesture;
|
|
107
|
+
composerTouchGesture = null;
|
|
108
|
+
suppressTouchFocusUntil = performance.now() + 800;
|
|
109
|
+
if (!gesture.moved) showComposerActionTooltip(gesture.control, true);
|
|
110
|
+
});
|
|
111
|
+
terminalControls.addEventListener('pointercancel', () => {
|
|
112
|
+
composerTouchGesture = null;
|
|
113
|
+
hideComposerActionTooltip();
|
|
114
|
+
});
|
|
115
|
+
terminalControls.addEventListener('scroll', hideComposerActionTooltip, true);
|
|
6
116
|
|
|
7
117
|
function isStructuredImageAttachmentAvailable() {
|
|
8
|
-
return isClaudeSession() ||
|
|
118
|
+
return isClaudeSession() || isCodexSession();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isSupportedImageFile(file) {
|
|
122
|
+
const type = String(file?.type || '').toLowerCase();
|
|
123
|
+
const name = String(file?.name || '').toLowerCase();
|
|
124
|
+
return ['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(type)
|
|
125
|
+
|| /\.(png|jpe?g|gif|webp)$/.test(name);
|
|
9
126
|
}
|
|
10
127
|
|
|
11
128
|
function syncComposerButtonState() {
|
|
12
129
|
const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
|
|
13
130
|
document.getElementById('schedule-send-btn').classList.toggle('active', timerOpen);
|
|
14
|
-
document.getElementById('
|
|
131
|
+
document.getElementById('attachment-btn').classList.toggle('active', selectedImageAttachments.length + selectedFileAttachments.length > 0);
|
|
15
132
|
}
|
|
16
133
|
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
134
|
+
function renderComposerAttachments() {
|
|
135
|
+
const chip = (item, kind) => `<div class="attachment-chip ${kind}${item.uploading ? ' uploading' : ''}"><svg class="attachment-chip-icon action-icon" aria-hidden="true"><use href="#icon-${kind === 'image' ? 'image' : 'file'}"></use></svg><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 || `Uploading ${kind}…`)}</span>` : ''}</span><button class="attachment-remove" type="button" title="Remove ${kind}" aria-label="Remove ${escapeHtml(item.name)}" onclick="${kind === 'image' ? 'removeImageAttachment' : 'removeFileAttachment'}('${item.id}')">×</button></div>`;
|
|
136
|
+
attachmentStrip.innerHTML = [
|
|
137
|
+
...selectedImageAttachments.map(item => chip(item, 'image')),
|
|
138
|
+
...selectedFileAttachments.map(item => chip(item, 'file'))
|
|
139
|
+
].join('');
|
|
140
|
+
attachmentStrip.classList.toggle('active', selectedImageAttachments.length + selectedFileAttachments.length > 0);
|
|
22
141
|
syncComposerButtonState();
|
|
23
142
|
updateTerminalControlsHeight();
|
|
24
143
|
}
|
|
@@ -26,7 +145,7 @@
|
|
|
26
145
|
window.removeImageAttachment = async function(attachmentId) {
|
|
27
146
|
const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
|
|
28
147
|
selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
|
|
29
|
-
|
|
148
|
+
renderComposerAttachments();
|
|
30
149
|
if (!attachment) return;
|
|
31
150
|
clearInterval(attachment.indicatorTimer);
|
|
32
151
|
attachment.abortUpload?.();
|
|
@@ -38,24 +157,43 @@
|
|
|
38
157
|
}
|
|
39
158
|
};
|
|
40
159
|
|
|
41
|
-
async function
|
|
160
|
+
window.removeFileAttachment = async function(attachmentId) {
|
|
161
|
+
const attachment = selectedFileAttachments.find(item => item.id === attachmentId);
|
|
162
|
+
selectedFileAttachments = selectedFileAttachments.filter(item => item.id !== attachmentId);
|
|
163
|
+
renderComposerAttachments();
|
|
164
|
+
if (!attachment) return;
|
|
165
|
+
attachment.abortUpload?.();
|
|
166
|
+
if (attachment.uploading) return;
|
|
167
|
+
try {
|
|
168
|
+
await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/files/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
169
|
+
} catch (_) {}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
async function clearComposerAttachments() {
|
|
42
173
|
const pending = selectedImageAttachments;
|
|
174
|
+
const pendingFiles = selectedFileAttachments;
|
|
43
175
|
selectedImageAttachments = [];
|
|
44
|
-
|
|
176
|
+
selectedFileAttachments = [];
|
|
177
|
+
renderComposerAttachments();
|
|
45
178
|
for (const item of pending) item.abortUpload?.();
|
|
179
|
+
for (const item of pendingFiles) item.abortUpload?.();
|
|
46
180
|
await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
|
|
47
181
|
`/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
|
|
48
182
|
{ method: 'DELETE' }
|
|
49
183
|
).catch(() => null)));
|
|
184
|
+
await Promise.all(pendingFiles.filter(item => !item.uploading).map(item => fetchWithTimeout(
|
|
185
|
+
`/api/sessions/${item.sessionId}/attachments/files/${encodeURIComponent(item.id)}`,
|
|
186
|
+
{ method: 'DELETE' }
|
|
187
|
+
).catch(() => null)));
|
|
50
188
|
}
|
|
51
189
|
|
|
52
|
-
const
|
|
190
|
+
const ATTACHMENT_UPLOAD_CHUNK_BYTES = 512 * 1024;
|
|
53
191
|
|
|
54
192
|
function uploadImageInChunks(sessionId, file, onProgress) {
|
|
55
193
|
let xhr = null;
|
|
56
194
|
let cancelled = false;
|
|
57
195
|
const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
58
|
-
const chunkTotal = Math.ceil(file.size /
|
|
196
|
+
const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
|
|
59
197
|
return {
|
|
60
198
|
abort: () => {
|
|
61
199
|
cancelled = true;
|
|
@@ -65,8 +203,8 @@
|
|
|
65
203
|
promise: (async () => {
|
|
66
204
|
for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
|
|
67
205
|
if (cancelled) throw new Error('Image upload cancelled');
|
|
68
|
-
const start = chunkIndex *
|
|
69
|
-
const chunk = file.slice(start, Math.min(file.size, start +
|
|
206
|
+
const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
|
|
207
|
+
const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
|
|
70
208
|
const result = await new Promise((resolve, reject) => {
|
|
71
209
|
xhr = new XMLHttpRequest();
|
|
72
210
|
xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
|
|
@@ -111,7 +249,7 @@
|
|
|
111
249
|
alert(`${file.name} is larger than 50 MB.`);
|
|
112
250
|
continue;
|
|
113
251
|
}
|
|
114
|
-
const chunkTotal = Math.ceil(file.size /
|
|
252
|
+
const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
|
|
115
253
|
const pending = {
|
|
116
254
|
id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
|
|
117
255
|
name: file.name || 'image',
|
|
@@ -123,12 +261,12 @@
|
|
|
123
261
|
abortUpload: null
|
|
124
262
|
};
|
|
125
263
|
selectedImageAttachments.push(pending);
|
|
126
|
-
|
|
264
|
+
renderComposerAttachments();
|
|
127
265
|
try {
|
|
128
266
|
const upload = uploadImageInChunks(activeSessionId, file, progress => {
|
|
129
267
|
pending.progress = progress;
|
|
130
268
|
pending.status = `${progress}%`;
|
|
131
|
-
|
|
269
|
+
renderComposerAttachments();
|
|
132
270
|
});
|
|
133
271
|
pending.abortUpload = upload.abort;
|
|
134
272
|
const attachment = await upload.promise;
|
|
@@ -138,16 +276,121 @@
|
|
|
138
276
|
continue;
|
|
139
277
|
}
|
|
140
278
|
selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
|
|
141
|
-
|
|
279
|
+
renderComposerAttachments();
|
|
142
280
|
} catch (e) {
|
|
143
281
|
selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
|
|
144
|
-
|
|
282
|
+
renderComposerAttachments();
|
|
145
283
|
if (e.message === 'Image upload cancelled') continue;
|
|
146
284
|
alert(`Could not add ${file.name}: ${e.message}`);
|
|
147
285
|
}
|
|
148
286
|
}
|
|
149
287
|
}
|
|
150
288
|
|
|
289
|
+
function uploadFileInChunks(sessionId, file, onProgress) {
|
|
290
|
+
let xhr = null;
|
|
291
|
+
let cancelled = false;
|
|
292
|
+
const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
293
|
+
const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
|
|
294
|
+
return {
|
|
295
|
+
abort: () => {
|
|
296
|
+
cancelled = true;
|
|
297
|
+
xhr?.abort();
|
|
298
|
+
void fetch(`/api/sessions/${sessionId}/attachments/files/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
|
|
299
|
+
},
|
|
300
|
+
promise: (async () => {
|
|
301
|
+
for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
|
|
302
|
+
if (cancelled) throw new Error('File upload cancelled');
|
|
303
|
+
const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
|
|
304
|
+
const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
|
|
305
|
+
const result = await new Promise((resolve, reject) => {
|
|
306
|
+
xhr = new XMLHttpRequest();
|
|
307
|
+
xhr.open('POST', `/api/sessions/${sessionId}/attachments/files/chunks`);
|
|
308
|
+
xhr.timeout = 60_000;
|
|
309
|
+
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
|
310
|
+
xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
|
|
311
|
+
xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
|
|
312
|
+
xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
|
|
313
|
+
xhr.setRequestHeader('X-Glad-File-Name', encodeURIComponent(file.name || 'attachment.bin'));
|
|
314
|
+
xhr.onerror = () => reject(new Error('Network error while uploading file'));
|
|
315
|
+
xhr.ontimeout = () => reject(new Error(`File upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
|
|
316
|
+
xhr.onabort = () => reject(new Error('File upload cancelled'));
|
|
317
|
+
xhr.onload = () => {
|
|
318
|
+
let data = {};
|
|
319
|
+
try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
|
|
320
|
+
if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
|
|
321
|
+
reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
resolve(data);
|
|
325
|
+
};
|
|
326
|
+
xhr.send(chunk);
|
|
327
|
+
});
|
|
328
|
+
onProgress(Math.round((Math.min(file.size, start + chunk.size) / file.size) * 100));
|
|
329
|
+
if (result.complete) return result.attachment;
|
|
330
|
+
}
|
|
331
|
+
throw new Error('File upload did not complete');
|
|
332
|
+
})()
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function uploadAttachmentFiles(files) {
|
|
337
|
+
const remaining = 8 - selectedFileAttachments.length;
|
|
338
|
+
const batch = Array.from(files).slice(0, remaining);
|
|
339
|
+
if (files.length > remaining) alert('You can attach up to 8 files at a time.');
|
|
340
|
+
for (const file of batch) {
|
|
341
|
+
if (!file.size) {
|
|
342
|
+
alert(`${file.name || 'File'} is empty.`);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (file.size > 50 * 1024 * 1024) {
|
|
346
|
+
alert(`${file.name} is larger than 50 MB.`);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const pending = {
|
|
350
|
+
id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
|
|
351
|
+
name: file.name || 'attachment.bin',
|
|
352
|
+
sessionId: activeSessionId,
|
|
353
|
+
uploading: true,
|
|
354
|
+
progress: 0,
|
|
355
|
+
progressKnown: true,
|
|
356
|
+
status: '0%',
|
|
357
|
+
abortUpload: null
|
|
358
|
+
};
|
|
359
|
+
selectedFileAttachments.push(pending);
|
|
360
|
+
renderComposerAttachments();
|
|
361
|
+
try {
|
|
362
|
+
const upload = uploadFileInChunks(activeSessionId, file, progress => {
|
|
363
|
+
pending.progress = progress;
|
|
364
|
+
pending.status = `${progress}%`;
|
|
365
|
+
renderComposerAttachments();
|
|
366
|
+
});
|
|
367
|
+
pending.abortUpload = upload.abort;
|
|
368
|
+
const attachment = await upload.promise;
|
|
369
|
+
const index = selectedFileAttachments.indexOf(pending);
|
|
370
|
+
if (index < 0) {
|
|
371
|
+
await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/files/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
selectedFileAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
|
|
375
|
+
renderComposerAttachments();
|
|
376
|
+
} catch (e) {
|
|
377
|
+
selectedFileAttachments = selectedFileAttachments.filter(item => item !== pending);
|
|
378
|
+
renderComposerAttachments();
|
|
379
|
+
if (e.message === 'File upload cancelled') continue;
|
|
380
|
+
alert(`Could not add ${file.name}: ${e.message}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function uploadSelectedAttachments(files) {
|
|
386
|
+
const selected = Array.from(files || []);
|
|
387
|
+
const images = isStructuredImageAttachmentAvailable()
|
|
388
|
+
? selected.filter(isSupportedImageFile) : [];
|
|
389
|
+
const regularFiles = selected.filter(file => !images.includes(file));
|
|
390
|
+
if (images.length) await uploadImageFiles(images);
|
|
391
|
+
if (regularFiles.length) await uploadAttachmentFiles(regularFiles);
|
|
392
|
+
}
|
|
393
|
+
|
|
151
394
|
function openTimedSendPanel() {
|
|
152
395
|
if (isClaudeSession()) {
|
|
153
396
|
closeClaudePicker();
|
|
@@ -179,45 +422,67 @@
|
|
|
179
422
|
});
|
|
180
423
|
|
|
181
424
|
function performSend() {
|
|
425
|
+
if (document.getElementById('send-btn').disabled) return;
|
|
182
426
|
const val = inputEl.value;
|
|
183
427
|
const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
|
|
184
|
-
|
|
185
|
-
|
|
428
|
+
const readyFileAttachments = selectedFileAttachments.filter(item => !item.uploading);
|
|
429
|
+
if (selectedImageAttachments.some(item => item.uploading) || selectedFileAttachments.some(item => item.uploading)) {
|
|
430
|
+
alert('Wait for attachments to finish uploading before sending.');
|
|
186
431
|
return;
|
|
187
432
|
}
|
|
188
|
-
if ((val || readyImageAttachments.length) && isClaudeSession()) {
|
|
433
|
+
if ((val || readyImageAttachments.length || readyFileAttachments.length) && isClaudeSession()) {
|
|
189
434
|
if (currentSocket && currentSocket.readyState === 1) {
|
|
190
435
|
currentSocket.send(JSON.stringify({
|
|
191
436
|
type: 'claude-input',
|
|
192
437
|
text: val,
|
|
193
|
-
attachmentIds: readyImageAttachments.map(item => item.id)
|
|
438
|
+
attachmentIds: readyImageAttachments.map(item => item.id),
|
|
439
|
+
...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {})
|
|
194
440
|
}));
|
|
441
|
+
markComposerSendPending();
|
|
195
442
|
}
|
|
196
443
|
inputEl.value = '';
|
|
197
444
|
inputEl.style.height = '38px';
|
|
198
445
|
selectedImageAttachments = [];
|
|
199
|
-
|
|
446
|
+
selectedFileAttachments = [];
|
|
447
|
+
renderComposerAttachments();
|
|
200
448
|
return;
|
|
201
449
|
}
|
|
202
|
-
if ((val || readyImageAttachments.length) && isCodexSession()
|
|
450
|
+
if ((val || readyImageAttachments.length || readyFileAttachments.length) && isCodexSession()) {
|
|
203
451
|
if (!codexReadyForInput()) return;
|
|
204
452
|
if (currentSocket && currentSocket.readyState === 1) {
|
|
205
453
|
currentSocket.send(JSON.stringify({
|
|
206
454
|
type: 'codex-input',
|
|
207
455
|
text: val,
|
|
208
456
|
attachmentIds: readyImageAttachments.map(item => item.id),
|
|
457
|
+
...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {}),
|
|
209
458
|
skills: selectedCodexSkill ? [{ name: selectedCodexSkill.name, path: selectedCodexSkill.path }] : []
|
|
210
459
|
}));
|
|
460
|
+
markComposerSendPending();
|
|
211
461
|
}
|
|
212
462
|
inputEl.value = '';
|
|
213
463
|
inputEl.style.height = '38px';
|
|
214
464
|
selectedImageAttachments = [];
|
|
465
|
+
selectedFileAttachments = [];
|
|
215
466
|
selectedCodexSkill = null;
|
|
216
|
-
|
|
467
|
+
renderComposerAttachments();
|
|
217
468
|
renderCodexChat();
|
|
218
469
|
return;
|
|
219
470
|
}
|
|
220
|
-
if (val) {
|
|
471
|
+
if (val || readyFileAttachments.length) {
|
|
472
|
+
if (readyFileAttachments.length) {
|
|
473
|
+
if (currentSocket && currentSocket.readyState === 1) {
|
|
474
|
+
currentSocket.send(JSON.stringify({
|
|
475
|
+
type: 'file-input',
|
|
476
|
+
text: val,
|
|
477
|
+
fileAttachmentIds: readyFileAttachments.map(item => item.id)
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
inputEl.value = '';
|
|
481
|
+
inputEl.style.height = '38px';
|
|
482
|
+
selectedFileAttachments = [];
|
|
483
|
+
renderComposerAttachments();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
221
486
|
const formattedVal = val.replace(/\n/g, '\r');
|
|
222
487
|
sendWS(formattedVal);
|
|
223
488
|
inputEl.value = '';
|
package/lib/web/core.js
CHANGED
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
}
|
|
19
19
|
button.textContent = label;
|
|
20
20
|
}
|
|
21
|
+
function renderSessionAttention(parts = []) {
|
|
22
|
+
const strip = document.getElementById('session-status-strip');
|
|
23
|
+
const rail = document.getElementById('session-attention-rail');
|
|
24
|
+
if (!strip || !rail) return;
|
|
25
|
+
rail.innerHTML = parts.join('');
|
|
26
|
+
strip.style.display = parts.length ? 'block' : 'none';
|
|
27
|
+
}
|
|
21
28
|
let claudeMessages = [];
|
|
22
29
|
let claudePendingPermissions = [];
|
|
23
30
|
let claudeStatus = 'idle';
|
|
@@ -44,9 +51,8 @@
|
|
|
44
51
|
permissionMode: 'default', sandboxMode: 'default',
|
|
45
52
|
effectivePermissionMode: null, effectiveSandboxMode: null,
|
|
46
53
|
model: null, effort: null, status: 'idle', threadId: null,
|
|
47
|
-
|
|
48
|
-
canAbort: false, canCompact: false, compacting: false
|
|
49
|
-
canSwitchToTerminal: false, canSwitchToStructured: false
|
|
54
|
+
models: [], aborting: false, resuming: false,
|
|
55
|
+
canAbort: false, canCompact: false, compacting: false
|
|
50
56
|
};
|
|
51
57
|
}
|
|
52
58
|
let codexMessages = [];
|
|
@@ -220,6 +226,40 @@
|
|
|
220
226
|
}
|
|
221
227
|
}
|
|
222
228
|
|
|
229
|
+
function renderSessionCard(session) {
|
|
230
|
+
const workingDirectory = session.workingDirectory || 'Unknown directory';
|
|
231
|
+
const encodedId = encodePathValue(session.id);
|
|
232
|
+
const encodedName = encodePathValue(session.name);
|
|
233
|
+
const encodedDir = encodePathValue(workingDirectory);
|
|
234
|
+
const encodedToolKey = encodePathValue(session.toolKey || '');
|
|
235
|
+
const timedInputCount = Number(session.timedInputCount) || 0;
|
|
236
|
+
const timerBadge = timedInputCount > 0
|
|
237
|
+
? `<span class="timer-count-badge" title="${timedInputCount} scheduled timer${timedInputCount > 1 ? 's' : ''}">
|
|
238
|
+
<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>
|
|
239
|
+
${timedInputCount}
|
|
240
|
+
</span>`
|
|
241
|
+
: '';
|
|
242
|
+
return `<div class="session-card${session.id === activeSessionId ? ' selected' : ''}" data-session-id="${escapeHtml(session.id)}">
|
|
243
|
+
<span class="active-session-dot" title="Current session" aria-label="Current session"></span>
|
|
244
|
+
<div class="session-info">
|
|
245
|
+
<h3><span class="session-name" title="${escapeHtml(session.name)}">${escapeHtml(session.name)}</span>${session.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button type="button" class="icon-btn session-edit-btn" title="Rename session" aria-label="Rename session" onclick="renameSession(decodePathValue('${encodedId}'), 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>
|
|
246
|
+
<p>${escapeHtml(session.tool)}</p>
|
|
247
|
+
<p>${new Date(session.startTime).toLocaleTimeString()}</p>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="session-actions">
|
|
250
|
+
${renderServerChanSessionAction(session)}
|
|
251
|
+
<button class="btn-join" onclick="joinSession(decodePathValue('${encodedId}'), decodePathValue('${encodedName}'), decodePathValue('${encodedToolKey}'))">Connect</button>
|
|
252
|
+
<button type="button" class="icon-btn btn-delete session-delete-btn" title="Delete session" aria-label="Delete session" onclick="deleteSession(decodePathValue('${encodedId}'), 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>
|
|
253
|
+
</div>
|
|
254
|
+
<div class="session-dir-row">
|
|
255
|
+
<button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
|
|
256
|
+
<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>
|
|
257
|
+
</button>
|
|
258
|
+
<p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
|
|
259
|
+
</div>
|
|
260
|
+
</div>`;
|
|
261
|
+
}
|
|
262
|
+
|
|
223
263
|
async function loadSessions() {
|
|
224
264
|
log('Loading sessions...');
|
|
225
265
|
const list = document.getElementById('sessions-list');
|
|
@@ -232,38 +272,7 @@
|
|
|
232
272
|
return;
|
|
233
273
|
}
|
|
234
274
|
|
|
235
|
-
|
|
236
|
-
sessions.forEach(s => {
|
|
237
|
-
const workingDirectory = s.workingDirectory || 'Unknown directory';
|
|
238
|
-
const encodedName = encodePathValue(s.name);
|
|
239
|
-
const encodedDir = encodePathValue(workingDirectory);
|
|
240
|
-
const timedInputCount = Number(s.timedInputCount) || 0;
|
|
241
|
-
const timerBadge = timedInputCount > 0
|
|
242
|
-
? `<span class="timer-count-badge" title="${timedInputCount} scheduled timer${timedInputCount > 1 ? 's' : ''}">
|
|
243
|
-
<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>
|
|
244
|
-
${timedInputCount}
|
|
245
|
-
</span>`
|
|
246
|
-
: '';
|
|
247
|
-
html += `<div class="session-card${s.id === activeSessionId ? ' selected' : ''}" data-session-id="${escapeHtml(s.id)}">
|
|
248
|
-
<div class="session-info">
|
|
249
|
-
<h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button type="button" class="icon-btn session-edit-btn" title="Rename session" aria-label="Rename session" 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>
|
|
250
|
-
<p>${escapeHtml(s.tool)}</p>
|
|
251
|
-
<p>${new Date(s.startTime).toLocaleTimeString()}</p>
|
|
252
|
-
</div>
|
|
253
|
-
<div class="session-actions">
|
|
254
|
-
${renderServerChanSessionAction(s)}
|
|
255
|
-
<button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
|
|
256
|
-
<button type="button" class="icon-btn btn-delete session-delete-btn" title="Delete session" aria-label="Delete session" 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>
|
|
257
|
-
</div>
|
|
258
|
-
<div class="session-dir-row">
|
|
259
|
-
<button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
|
|
260
|
-
<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>
|
|
261
|
-
</button>
|
|
262
|
-
<p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
|
|
263
|
-
</div>
|
|
264
|
-
</div>`;
|
|
265
|
-
});
|
|
266
|
-
list.innerHTML = html;
|
|
275
|
+
list.innerHTML = sessions.map(renderSessionCard).join('');
|
|
267
276
|
} catch (e) {
|
|
268
277
|
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>`;
|
|
269
278
|
}
|
|
@@ -284,13 +293,19 @@
|
|
|
284
293
|
}
|
|
285
294
|
}
|
|
286
295
|
|
|
287
|
-
async function showToolModal() {
|
|
296
|
+
async function showToolModal(skill = null) {
|
|
297
|
+
window.pendingSkillHubSkill = skill || null;
|
|
288
298
|
document.getElementById('modal-overlay').style.display = 'flex';
|
|
289
299
|
const list = document.getElementById('tools-list');
|
|
300
|
+
const title = document.getElementById('tool-modal-title');
|
|
301
|
+
const skillLabel = document.getElementById('tool-modal-skill');
|
|
302
|
+
if (title) title.textContent = skill ? 'Start Skill Session' : 'Create Session';
|
|
303
|
+
if (skillLabel) skillLabel.textContent = skill ? String(skill.displayName || skill.name || '') : '';
|
|
290
304
|
loadAppConfig();
|
|
291
305
|
try {
|
|
292
306
|
const res = await fetchWithTimeout('/api/tools');
|
|
293
|
-
|
|
307
|
+
let tools = await res.json();
|
|
308
|
+
if (skill) tools = tools.filter(tool => tool.key === 'codex');
|
|
294
309
|
|
|
295
310
|
let html = '';
|
|
296
311
|
tools.forEach(t => {
|
|
@@ -325,7 +340,10 @@
|
|
|
325
340
|
}
|
|
326
341
|
|
|
327
342
|
function closeToolModal(e) {
|
|
328
|
-
if (e.target.id === 'modal-overlay')
|
|
343
|
+
if (!e || e.target.id === 'modal-overlay') {
|
|
344
|
+
document.getElementById('modal-overlay').style.display = 'none';
|
|
345
|
+
window.pendingSkillHubSkill = null;
|
|
346
|
+
}
|
|
329
347
|
}
|
|
330
348
|
|
|
331
349
|
function isLobbyVisible() {
|