glad-web 1.0.46 → 2.0.2
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 +4 -192
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/bin/glad.cjs +56 -0
- package/package.json +19 -61
- package/README.zh-CN.md +0 -198
- package/assets/logo.svg +0 -43
- package/bin/cli.js +0 -65
- package/lib/ai-tools/demo/enhanced-demo.js +0 -625
- package/lib/ai-tools/demo/index.js +0 -24
- package/lib/ai-tools/demo/responses.js +0 -88
- package/lib/ai-tools/detector.js +0 -76
- package/lib/ai-tools/registry.js +0 -300
- package/lib/claude/cli-usage.js +0 -95
- package/lib/claude/config.js +0 -82
- package/lib/claude/structured-session.js +0 -884
- package/lib/claude/transcript-repository.js +0 -216
- package/lib/codex/image-store.js +0 -174
- package/lib/codex/structured-session.js +0 -1590
- package/lib/commands/config.js +0 -78
- package/lib/commands/tools.js +0 -128
- package/lib/commands/web.js +0 -605
- package/lib/config/constants.js +0 -17
- package/lib/config/manager.js +0 -108
- package/lib/git/service.js +0 -83
- package/lib/notifications/message-formatter.js +0 -94
- package/lib/notifications/notification-service.js +0 -143
- package/lib/notifications/serverchan-client.js +0 -58
- package/lib/notifications/serverchan-settings-store.js +0 -115
- package/lib/schedule/job-runner.js +0 -162
- package/lib/schedule/job-store.js +0 -167
- package/lib/schedule/key-sequences.js +0 -49
- package/lib/schedule/scheduler-service.js +0 -39
- package/lib/server/routes/notifications.js +0 -52
- package/lib/server/routes/providers.js +0 -114
- package/lib/server/routes/schedules.js +0 -54
- package/lib/server/routes/skillhub.js +0 -104
- package/lib/server/routes/usage.js +0 -23
- package/lib/server/routes/workspace.js +0 -77
- package/lib/session/buffer.js +0 -102
- package/lib/session/file-attachment-store.js +0 -168
- package/lib/session/pty-manager.js +0 -255
- package/lib/session/rendered-history.js +0 -225
- package/lib/session/session-manager.js +0 -1032
- package/lib/session/text-history.js +0 -274
- package/lib/skillhub/client.js +0 -121
- package/lib/skillhub/settings-store.js +0 -168
- package/lib/skillhub/skill-installer.js +0 -320
- package/lib/usage/ccusage-runner.js +0 -128
- package/lib/usage/source-catalog.js +0 -26
- package/lib/usage/usage-service.js +0 -226
- package/lib/utils/logger.js +0 -74
- package/lib/utils/pid.js +0 -67
- package/lib/utils/validation.js +0 -53
- package/lib/web/bootstrap.js +0 -34
- package/lib/web/claude.js +0 -1150
- package/lib/web/codex.js +0 -1045
- package/lib/web/composer.js +0 -493
- package/lib/web/core.js +0 -385
- package/lib/web/git.js +0 -535
- package/lib/web/gitgraph.js +0 -293
- package/lib/web/index.html +0 -547
- package/lib/web/layout.js +0 -69
- package/lib/web/notifications.js +0 -164
- package/lib/web/schedules.js +0 -245
- package/lib/web/session.js +0 -361
- package/lib/web/shell.js +0 -74
- package/lib/web/skillhub.js +0 -197
- package/lib/web/styles.css +0 -932
- package/lib/web/terminal-scroll.js +0 -81
- package/lib/web/theme.js +0 -60
- package/lib/web/timed-inputs.js +0 -216
- package/lib/web/usage.js +0 -323
- package/lib/workspace/service.js +0 -77
- package/scripts/check-syntax.js +0 -26
package/lib/web/composer.js
DELETED
|
@@ -1,493 +0,0 @@
|
|
|
1
|
-
const inputEl = document.getElementById('cmd-input');
|
|
2
|
-
const attachmentFileInput = document.getElementById('attachment-file-input');
|
|
3
|
-
const attachmentStrip = document.getElementById('attachment-strip');
|
|
4
|
-
let selectedImageAttachments = [];
|
|
5
|
-
let selectedFileAttachments = [];
|
|
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);
|
|
116
|
-
|
|
117
|
-
function isStructuredImageAttachmentAvailable() {
|
|
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);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function syncComposerButtonState() {
|
|
129
|
-
const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
|
|
130
|
-
document.getElementById('schedule-send-btn').classList.toggle('active', timerOpen);
|
|
131
|
-
document.getElementById('attachment-btn').classList.toggle('active', selectedImageAttachments.length + selectedFileAttachments.length > 0);
|
|
132
|
-
}
|
|
133
|
-
|
|
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);
|
|
141
|
-
syncComposerButtonState();
|
|
142
|
-
updateTerminalControlsHeight();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
window.removeImageAttachment = async function(attachmentId) {
|
|
146
|
-
const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
|
|
147
|
-
selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
|
|
148
|
-
renderComposerAttachments();
|
|
149
|
-
if (!attachment) return;
|
|
150
|
-
clearInterval(attachment.indicatorTimer);
|
|
151
|
-
attachment.abortUpload?.();
|
|
152
|
-
if (attachment.uploading) return;
|
|
153
|
-
try {
|
|
154
|
-
await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
155
|
-
} catch (_) {
|
|
156
|
-
// The server also removes all attachments when the session ends.
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
|
|
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() {
|
|
173
|
-
const pending = selectedImageAttachments;
|
|
174
|
-
const pendingFiles = selectedFileAttachments;
|
|
175
|
-
selectedImageAttachments = [];
|
|
176
|
-
selectedFileAttachments = [];
|
|
177
|
-
renderComposerAttachments();
|
|
178
|
-
for (const item of pending) item.abortUpload?.();
|
|
179
|
-
for (const item of pendingFiles) item.abortUpload?.();
|
|
180
|
-
await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
|
|
181
|
-
`/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
|
|
182
|
-
{ method: 'DELETE' }
|
|
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)));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const ATTACHMENT_UPLOAD_CHUNK_BYTES = 512 * 1024;
|
|
191
|
-
|
|
192
|
-
function uploadImageInChunks(sessionId, file, onProgress) {
|
|
193
|
-
let xhr = null;
|
|
194
|
-
let cancelled = false;
|
|
195
|
-
const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
196
|
-
const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
|
|
197
|
-
return {
|
|
198
|
-
abort: () => {
|
|
199
|
-
cancelled = true;
|
|
200
|
-
xhr?.abort();
|
|
201
|
-
void fetch(`/api/sessions/${sessionId}/attachments/images/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
|
|
202
|
-
},
|
|
203
|
-
promise: (async () => {
|
|
204
|
-
for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
|
|
205
|
-
if (cancelled) throw new Error('Image upload cancelled');
|
|
206
|
-
const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
|
|
207
|
-
const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
|
|
208
|
-
const result = await new Promise((resolve, reject) => {
|
|
209
|
-
xhr = new XMLHttpRequest();
|
|
210
|
-
xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
|
|
211
|
-
xhr.timeout = 60_000;
|
|
212
|
-
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
|
213
|
-
xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
|
|
214
|
-
xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
|
|
215
|
-
xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
|
|
216
|
-
xhr.onerror = () => reject(new Error('Network error while uploading image'));
|
|
217
|
-
xhr.ontimeout = () => reject(new Error(`Image upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
|
|
218
|
-
xhr.onabort = () => reject(new Error('Image upload cancelled'));
|
|
219
|
-
xhr.onload = () => {
|
|
220
|
-
let data = {};
|
|
221
|
-
try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
|
|
222
|
-
if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
|
|
223
|
-
reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
resolve(data);
|
|
227
|
-
};
|
|
228
|
-
xhr.send(chunk);
|
|
229
|
-
});
|
|
230
|
-
const confirmedBytes = Math.min(file.size, start + chunk.size);
|
|
231
|
-
onProgress(Math.round((confirmedBytes / file.size) * 100), chunkIndex + 1, chunkTotal);
|
|
232
|
-
if (result.complete) return result.attachment;
|
|
233
|
-
}
|
|
234
|
-
throw new Error('Image upload did not complete');
|
|
235
|
-
})()
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function uploadImageFiles(files) {
|
|
240
|
-
if (!isStructuredImageAttachmentAvailable()) {
|
|
241
|
-
alert('Image attachments are available only in structured chat mode.');
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
const remaining = 5 - selectedImageAttachments.length;
|
|
245
|
-
const batch = Array.from(files).slice(0, remaining);
|
|
246
|
-
if (files.length > remaining) alert('You can attach up to 5 images at a time.');
|
|
247
|
-
for (const file of batch) {
|
|
248
|
-
if (file.size > 50 * 1024 * 1024) {
|
|
249
|
-
alert(`${file.name} is larger than 50 MB.`);
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
|
|
253
|
-
const pending = {
|
|
254
|
-
id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
|
|
255
|
-
name: file.name || 'image',
|
|
256
|
-
sessionId: activeSessionId,
|
|
257
|
-
uploading: true,
|
|
258
|
-
progress: 0,
|
|
259
|
-
progressKnown: true,
|
|
260
|
-
status: '0%',
|
|
261
|
-
abortUpload: null
|
|
262
|
-
};
|
|
263
|
-
selectedImageAttachments.push(pending);
|
|
264
|
-
renderComposerAttachments();
|
|
265
|
-
try {
|
|
266
|
-
const upload = uploadImageInChunks(activeSessionId, file, progress => {
|
|
267
|
-
pending.progress = progress;
|
|
268
|
-
pending.status = `${progress}%`;
|
|
269
|
-
renderComposerAttachments();
|
|
270
|
-
});
|
|
271
|
-
pending.abortUpload = upload.abort;
|
|
272
|
-
const attachment = await upload.promise;
|
|
273
|
-
const index = selectedImageAttachments.indexOf(pending);
|
|
274
|
-
if (index < 0) {
|
|
275
|
-
await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
|
|
279
|
-
renderComposerAttachments();
|
|
280
|
-
} catch (e) {
|
|
281
|
-
selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
|
|
282
|
-
renderComposerAttachments();
|
|
283
|
-
if (e.message === 'Image upload cancelled') continue;
|
|
284
|
-
alert(`Could not add ${file.name}: ${e.message}`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
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
|
-
|
|
394
|
-
function openTimedSendPanel() {
|
|
395
|
-
if (isClaudeSession()) {
|
|
396
|
-
closeClaudePicker();
|
|
397
|
-
claudeResumePanelOpen = false;
|
|
398
|
-
claudeForkPanelOpen = false;
|
|
399
|
-
document.getElementById('claude-resume-panel').classList.remove('active');
|
|
400
|
-
document.getElementById('claude-fork-panel').classList.remove('active');
|
|
401
|
-
}
|
|
402
|
-
initTimedDelaySelectors();
|
|
403
|
-
resetTimedEditor({ keepInput: true });
|
|
404
|
-
document.getElementById('timed-send-panel').classList.add('active');
|
|
405
|
-
updateTimedSendPreview();
|
|
406
|
-
loadTimedInputs();
|
|
407
|
-
syncComposerButtonState();
|
|
408
|
-
updateTerminalControlsHeight();
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function markInputEditStart() {
|
|
412
|
-
keepTerminalBottomForNextInput = keepTerminalBottomForNextInput || isTerminalAtBottom();
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
inputEl.addEventListener('beforeinput', markInputEditStart);
|
|
416
|
-
inputEl.addEventListener('input', function() {
|
|
417
|
-
const keepAtBottom = keepTerminalBottomForNextInput || isTerminalAtBottom();
|
|
418
|
-
keepTerminalBottomForNextInput = false;
|
|
419
|
-
this.style.height = 'auto';
|
|
420
|
-
this.style.height = Math.min(this.scrollHeight, 150) + 'px';
|
|
421
|
-
if (keepAtBottom) restoreTerminalBottomSoon();
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
function performSend() {
|
|
425
|
-
if (document.getElementById('send-btn').disabled) return;
|
|
426
|
-
const val = inputEl.value;
|
|
427
|
-
const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
|
|
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.');
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
if ((val || readyImageAttachments.length || readyFileAttachments.length) && isClaudeSession()) {
|
|
434
|
-
if (currentSocket && currentSocket.readyState === 1) {
|
|
435
|
-
currentSocket.send(JSON.stringify({
|
|
436
|
-
type: 'claude-input',
|
|
437
|
-
text: val,
|
|
438
|
-
attachmentIds: readyImageAttachments.map(item => item.id),
|
|
439
|
-
...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {})
|
|
440
|
-
}));
|
|
441
|
-
markComposerSendPending();
|
|
442
|
-
}
|
|
443
|
-
inputEl.value = '';
|
|
444
|
-
inputEl.style.height = '38px';
|
|
445
|
-
selectedImageAttachments = [];
|
|
446
|
-
selectedFileAttachments = [];
|
|
447
|
-
renderComposerAttachments();
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
if ((val || readyImageAttachments.length || readyFileAttachments.length) && isCodexSession()) {
|
|
451
|
-
if (!codexReadyForInput()) return;
|
|
452
|
-
if (currentSocket && currentSocket.readyState === 1) {
|
|
453
|
-
currentSocket.send(JSON.stringify({
|
|
454
|
-
type: 'codex-input',
|
|
455
|
-
text: val,
|
|
456
|
-
attachmentIds: readyImageAttachments.map(item => item.id),
|
|
457
|
-
...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {}),
|
|
458
|
-
skills: selectedCodexSkill ? [{ name: selectedCodexSkill.name, path: selectedCodexSkill.path }] : []
|
|
459
|
-
}));
|
|
460
|
-
markComposerSendPending();
|
|
461
|
-
}
|
|
462
|
-
inputEl.value = '';
|
|
463
|
-
inputEl.style.height = '38px';
|
|
464
|
-
selectedImageAttachments = [];
|
|
465
|
-
selectedFileAttachments = [];
|
|
466
|
-
selectedCodexSkill = null;
|
|
467
|
-
renderComposerAttachments();
|
|
468
|
-
renderCodexChat();
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
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
|
-
}
|
|
486
|
-
const formattedVal = val.replace(/\n/g, '\r');
|
|
487
|
-
sendWS(formattedVal);
|
|
488
|
-
inputEl.value = '';
|
|
489
|
-
inputEl.style.height = '38px';
|
|
490
|
-
restoreTerminalBottomSoon();
|
|
491
|
-
setTimeout(() => { sendWS('\r'); }, 1000);
|
|
492
|
-
}
|
|
493
|
-
}
|