glad-web 1.0.43 → 1.0.45
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 +24 -73
- package/lib/commands/web.js +43 -5
- package/lib/server/routes/providers.js +0 -12
- package/lib/session/file-attachment-store.js +168 -0
- package/lib/session/session-manager.js +75 -23
- package/lib/web/claude.js +28 -11
- package/lib/web/codex.js +49 -23
- package/lib/web/composer.js +263 -34
- package/lib/web/core.js +22 -6
- package/lib/web/git.js +53 -51
- package/lib/web/gitgraph.js +8 -8
- package/lib/web/index.html +94 -36
- package/lib/web/layout.js +72 -0
- package/lib/web/notifications.js +1 -0
- package/lib/web/session.js +6 -20
- package/lib/web/shell.js +3 -0
- package/lib/web/styles.css +375 -11
- package/lib/web/theme.js +60 -0
- package/lib/web/timed-inputs.js +9 -16
- package/package.json +1 -1
package/lib/web/composer.js
CHANGED
|
@@ -1,31 +1,124 @@
|
|
|
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
|
+
|
|
12
|
+
function composerActionControl(target) {
|
|
13
|
+
const control = target instanceof Element ? target.closest('button, label') : null;
|
|
14
|
+
if (!control || !document.getElementById('terminal-controls').contains(control)) return null;
|
|
15
|
+
return control.querySelector('.action-icon') ? control : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function composerActionName(control) {
|
|
19
|
+
return control?.querySelector('.action-label')?.textContent?.trim()
|
|
20
|
+
|| control?.getAttribute('aria-label')
|
|
21
|
+
|| control?.getAttribute('title')
|
|
22
|
+
|| '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hideComposerActionTooltip() {
|
|
26
|
+
clearTimeout(composerActionTooltipTimer);
|
|
27
|
+
composerActionTooltipTimer = null;
|
|
28
|
+
composerActionTooltip.classList.remove('visible');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function showComposerActionTooltip(control, autoHide = false) {
|
|
32
|
+
const label = composerActionName(control);
|
|
33
|
+
if (!label) return;
|
|
34
|
+
clearTimeout(composerActionTooltipTimer);
|
|
35
|
+
composerActionTooltip.textContent = label;
|
|
36
|
+
composerActionTooltip.classList.add('visible');
|
|
37
|
+
const controlBox = control.getBoundingClientRect();
|
|
38
|
+
const tooltipBox = composerActionTooltip.getBoundingClientRect();
|
|
39
|
+
const left = Math.max(8, Math.min(window.innerWidth - tooltipBox.width - 8,
|
|
40
|
+
controlBox.left + controlBox.width / 2 - tooltipBox.width / 2));
|
|
41
|
+
const above = controlBox.top - tooltipBox.height - 8;
|
|
42
|
+
composerActionTooltip.style.left = `${left}px`;
|
|
43
|
+
composerActionTooltip.style.top = `${above >= 8 ? above : controlBox.bottom + 8}px`;
|
|
44
|
+
if (autoHide) composerActionTooltipTimer = setTimeout(hideComposerActionTooltip, 1400);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const terminalControls = document.getElementById('terminal-controls');
|
|
48
|
+
terminalControls.addEventListener('focusin', event => {
|
|
49
|
+
if (performance.now() < suppressTouchFocusUntil) return;
|
|
50
|
+
const control = composerActionControl(event.target);
|
|
51
|
+
if (control) showComposerActionTooltip(control);
|
|
52
|
+
});
|
|
53
|
+
terminalControls.addEventListener('focusout', hideComposerActionTooltip);
|
|
54
|
+
terminalControls.addEventListener('pointerover', event => {
|
|
55
|
+
const control = composerActionControl(event.target);
|
|
56
|
+
if (control && event.pointerType !== 'touch') showComposerActionTooltip(control);
|
|
57
|
+
});
|
|
58
|
+
terminalControls.addEventListener('pointerout', event => {
|
|
59
|
+
const control = composerActionControl(event.target);
|
|
60
|
+
if (control && !control.contains(event.relatedTarget)) hideComposerActionTooltip();
|
|
61
|
+
});
|
|
62
|
+
terminalControls.addEventListener('pointerdown', event => {
|
|
63
|
+
const control = composerActionControl(event.target);
|
|
64
|
+
if (control && event.pointerType === 'touch') {
|
|
65
|
+
composerTouchGesture = { pointerId: event.pointerId, control, x: event.clientX, y: event.clientY, moved: false };
|
|
66
|
+
suppressTouchFocusUntil = performance.now() + 800;
|
|
67
|
+
hideComposerActionTooltip();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
terminalControls.addEventListener('pointermove', event => {
|
|
71
|
+
if (!composerTouchGesture || event.pointerId !== composerTouchGesture.pointerId) return;
|
|
72
|
+
if (Math.hypot(event.clientX - composerTouchGesture.x, event.clientY - composerTouchGesture.y) > 8) {
|
|
73
|
+
composerTouchGesture.moved = true;
|
|
74
|
+
hideComposerActionTooltip();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
terminalControls.addEventListener('pointerup', event => {
|
|
78
|
+
if (!composerTouchGesture || event.pointerId !== composerTouchGesture.pointerId) return;
|
|
79
|
+
const gesture = composerTouchGesture;
|
|
80
|
+
composerTouchGesture = null;
|
|
81
|
+
suppressTouchFocusUntil = performance.now() + 800;
|
|
82
|
+
if (!gesture.moved) showComposerActionTooltip(gesture.control, true);
|
|
83
|
+
});
|
|
84
|
+
terminalControls.addEventListener('pointercancel', () => {
|
|
85
|
+
composerTouchGesture = null;
|
|
86
|
+
hideComposerActionTooltip();
|
|
87
|
+
});
|
|
88
|
+
terminalControls.addEventListener('scroll', hideComposerActionTooltip, true);
|
|
6
89
|
|
|
7
90
|
function isStructuredImageAttachmentAvailable() {
|
|
8
|
-
return isClaudeSession() ||
|
|
91
|
+
return isClaudeSession() || isCodexSession();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isSupportedImageFile(file) {
|
|
95
|
+
const type = String(file?.type || '').toLowerCase();
|
|
96
|
+
const name = String(file?.name || '').toLowerCase();
|
|
97
|
+
return ['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(type)
|
|
98
|
+
|| /\.(png|jpe?g|gif|webp)$/.test(name);
|
|
9
99
|
}
|
|
10
100
|
|
|
11
101
|
function syncComposerButtonState() {
|
|
12
|
-
const menuOpen = document.getElementById('composer-menu').classList.contains('active');
|
|
13
102
|
const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
|
|
14
|
-
document.getElementById('
|
|
103
|
+
document.getElementById('schedule-send-btn').classList.toggle('active', timerOpen);
|
|
104
|
+
document.getElementById('attachment-btn').classList.toggle('active', selectedImageAttachments.length + selectedFileAttachments.length > 0);
|
|
15
105
|
}
|
|
16
106
|
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
107
|
+
function renderComposerAttachments() {
|
|
108
|
+
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>`;
|
|
109
|
+
attachmentStrip.innerHTML = [
|
|
110
|
+
...selectedImageAttachments.map(item => chip(item, 'image')),
|
|
111
|
+
...selectedFileAttachments.map(item => chip(item, 'file'))
|
|
112
|
+
].join('');
|
|
113
|
+
attachmentStrip.classList.toggle('active', selectedImageAttachments.length + selectedFileAttachments.length > 0);
|
|
114
|
+
syncComposerButtonState();
|
|
22
115
|
updateTerminalControlsHeight();
|
|
23
116
|
}
|
|
24
117
|
|
|
25
118
|
window.removeImageAttachment = async function(attachmentId) {
|
|
26
119
|
const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
|
|
27
120
|
selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
|
|
28
|
-
|
|
121
|
+
renderComposerAttachments();
|
|
29
122
|
if (!attachment) return;
|
|
30
123
|
clearInterval(attachment.indicatorTimer);
|
|
31
124
|
attachment.abortUpload?.();
|
|
@@ -37,24 +130,43 @@
|
|
|
37
130
|
}
|
|
38
131
|
};
|
|
39
132
|
|
|
40
|
-
async function
|
|
133
|
+
window.removeFileAttachment = async function(attachmentId) {
|
|
134
|
+
const attachment = selectedFileAttachments.find(item => item.id === attachmentId);
|
|
135
|
+
selectedFileAttachments = selectedFileAttachments.filter(item => item.id !== attachmentId);
|
|
136
|
+
renderComposerAttachments();
|
|
137
|
+
if (!attachment) return;
|
|
138
|
+
attachment.abortUpload?.();
|
|
139
|
+
if (attachment.uploading) return;
|
|
140
|
+
try {
|
|
141
|
+
await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/files/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
142
|
+
} catch (_) {}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
async function clearComposerAttachments() {
|
|
41
146
|
const pending = selectedImageAttachments;
|
|
147
|
+
const pendingFiles = selectedFileAttachments;
|
|
42
148
|
selectedImageAttachments = [];
|
|
43
|
-
|
|
149
|
+
selectedFileAttachments = [];
|
|
150
|
+
renderComposerAttachments();
|
|
44
151
|
for (const item of pending) item.abortUpload?.();
|
|
152
|
+
for (const item of pendingFiles) item.abortUpload?.();
|
|
45
153
|
await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
|
|
46
154
|
`/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
|
|
47
155
|
{ method: 'DELETE' }
|
|
48
156
|
).catch(() => null)));
|
|
157
|
+
await Promise.all(pendingFiles.filter(item => !item.uploading).map(item => fetchWithTimeout(
|
|
158
|
+
`/api/sessions/${item.sessionId}/attachments/files/${encodeURIComponent(item.id)}`,
|
|
159
|
+
{ method: 'DELETE' }
|
|
160
|
+
).catch(() => null)));
|
|
49
161
|
}
|
|
50
162
|
|
|
51
|
-
const
|
|
163
|
+
const ATTACHMENT_UPLOAD_CHUNK_BYTES = 512 * 1024;
|
|
52
164
|
|
|
53
165
|
function uploadImageInChunks(sessionId, file, onProgress) {
|
|
54
166
|
let xhr = null;
|
|
55
167
|
let cancelled = false;
|
|
56
168
|
const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
57
|
-
const chunkTotal = Math.ceil(file.size /
|
|
169
|
+
const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
|
|
58
170
|
return {
|
|
59
171
|
abort: () => {
|
|
60
172
|
cancelled = true;
|
|
@@ -64,8 +176,8 @@
|
|
|
64
176
|
promise: (async () => {
|
|
65
177
|
for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
|
|
66
178
|
if (cancelled) throw new Error('Image upload cancelled');
|
|
67
|
-
const start = chunkIndex *
|
|
68
|
-
const chunk = file.slice(start, Math.min(file.size, start +
|
|
179
|
+
const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
|
|
180
|
+
const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
|
|
69
181
|
const result = await new Promise((resolve, reject) => {
|
|
70
182
|
xhr = new XMLHttpRequest();
|
|
71
183
|
xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
|
|
@@ -110,7 +222,7 @@
|
|
|
110
222
|
alert(`${file.name} is larger than 50 MB.`);
|
|
111
223
|
continue;
|
|
112
224
|
}
|
|
113
|
-
const chunkTotal = Math.ceil(file.size /
|
|
225
|
+
const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
|
|
114
226
|
const pending = {
|
|
115
227
|
id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
|
|
116
228
|
name: file.name || 'image',
|
|
@@ -122,12 +234,12 @@
|
|
|
122
234
|
abortUpload: null
|
|
123
235
|
};
|
|
124
236
|
selectedImageAttachments.push(pending);
|
|
125
|
-
|
|
237
|
+
renderComposerAttachments();
|
|
126
238
|
try {
|
|
127
239
|
const upload = uploadImageInChunks(activeSessionId, file, progress => {
|
|
128
240
|
pending.progress = progress;
|
|
129
241
|
pending.status = `${progress}%`;
|
|
130
|
-
|
|
242
|
+
renderComposerAttachments();
|
|
131
243
|
});
|
|
132
244
|
pending.abortUpload = upload.abort;
|
|
133
245
|
const attachment = await upload.promise;
|
|
@@ -137,24 +249,122 @@
|
|
|
137
249
|
continue;
|
|
138
250
|
}
|
|
139
251
|
selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
|
|
140
|
-
|
|
252
|
+
renderComposerAttachments();
|
|
141
253
|
} catch (e) {
|
|
142
254
|
selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
|
|
143
|
-
|
|
255
|
+
renderComposerAttachments();
|
|
144
256
|
if (e.message === 'Image upload cancelled') continue;
|
|
145
257
|
alert(`Could not add ${file.name}: ${e.message}`);
|
|
146
258
|
}
|
|
147
259
|
}
|
|
148
260
|
}
|
|
149
261
|
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
262
|
+
function uploadFileInChunks(sessionId, file, onProgress) {
|
|
263
|
+
let xhr = null;
|
|
264
|
+
let cancelled = false;
|
|
265
|
+
const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
266
|
+
const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
|
|
267
|
+
return {
|
|
268
|
+
abort: () => {
|
|
269
|
+
cancelled = true;
|
|
270
|
+
xhr?.abort();
|
|
271
|
+
void fetch(`/api/sessions/${sessionId}/attachments/files/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
|
|
272
|
+
},
|
|
273
|
+
promise: (async () => {
|
|
274
|
+
for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
|
|
275
|
+
if (cancelled) throw new Error('File upload cancelled');
|
|
276
|
+
const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
|
|
277
|
+
const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
|
|
278
|
+
const result = await new Promise((resolve, reject) => {
|
|
279
|
+
xhr = new XMLHttpRequest();
|
|
280
|
+
xhr.open('POST', `/api/sessions/${sessionId}/attachments/files/chunks`);
|
|
281
|
+
xhr.timeout = 60_000;
|
|
282
|
+
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
|
283
|
+
xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
|
|
284
|
+
xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
|
|
285
|
+
xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
|
|
286
|
+
xhr.setRequestHeader('X-Glad-File-Name', encodeURIComponent(file.name || 'attachment.bin'));
|
|
287
|
+
xhr.onerror = () => reject(new Error('Network error while uploading file'));
|
|
288
|
+
xhr.ontimeout = () => reject(new Error(`File upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
|
|
289
|
+
xhr.onabort = () => reject(new Error('File upload cancelled'));
|
|
290
|
+
xhr.onload = () => {
|
|
291
|
+
let data = {};
|
|
292
|
+
try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
|
|
293
|
+
if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
|
|
294
|
+
reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
resolve(data);
|
|
298
|
+
};
|
|
299
|
+
xhr.send(chunk);
|
|
300
|
+
});
|
|
301
|
+
onProgress(Math.round((Math.min(file.size, start + chunk.size) / file.size) * 100));
|
|
302
|
+
if (result.complete) return result.attachment;
|
|
303
|
+
}
|
|
304
|
+
throw new Error('File upload did not complete');
|
|
305
|
+
})()
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function uploadAttachmentFiles(files) {
|
|
310
|
+
const remaining = 8 - selectedFileAttachments.length;
|
|
311
|
+
const batch = Array.from(files).slice(0, remaining);
|
|
312
|
+
if (files.length > remaining) alert('You can attach up to 8 files at a time.');
|
|
313
|
+
for (const file of batch) {
|
|
314
|
+
if (!file.size) {
|
|
315
|
+
alert(`${file.name || 'File'} is empty.`);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (file.size > 50 * 1024 * 1024) {
|
|
319
|
+
alert(`${file.name} is larger than 50 MB.`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const pending = {
|
|
323
|
+
id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
|
|
324
|
+
name: file.name || 'attachment.bin',
|
|
325
|
+
sessionId: activeSessionId,
|
|
326
|
+
uploading: true,
|
|
327
|
+
progress: 0,
|
|
328
|
+
progressKnown: true,
|
|
329
|
+
status: '0%',
|
|
330
|
+
abortUpload: null
|
|
331
|
+
};
|
|
332
|
+
selectedFileAttachments.push(pending);
|
|
333
|
+
renderComposerAttachments();
|
|
334
|
+
try {
|
|
335
|
+
const upload = uploadFileInChunks(activeSessionId, file, progress => {
|
|
336
|
+
pending.progress = progress;
|
|
337
|
+
pending.status = `${progress}%`;
|
|
338
|
+
renderComposerAttachments();
|
|
339
|
+
});
|
|
340
|
+
pending.abortUpload = upload.abort;
|
|
341
|
+
const attachment = await upload.promise;
|
|
342
|
+
const index = selectedFileAttachments.indexOf(pending);
|
|
343
|
+
if (index < 0) {
|
|
344
|
+
await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/files/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
selectedFileAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
|
|
348
|
+
renderComposerAttachments();
|
|
349
|
+
} catch (e) {
|
|
350
|
+
selectedFileAttachments = selectedFileAttachments.filter(item => item !== pending);
|
|
351
|
+
renderComposerAttachments();
|
|
352
|
+
if (e.message === 'File upload cancelled') continue;
|
|
353
|
+
alert(`Could not add ${file.name}: ${e.message}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function uploadSelectedAttachments(files) {
|
|
359
|
+
const selected = Array.from(files || []);
|
|
360
|
+
const images = isStructuredImageAttachmentAvailable()
|
|
361
|
+
? selected.filter(isSupportedImageFile) : [];
|
|
362
|
+
const regularFiles = selected.filter(file => !images.includes(file));
|
|
363
|
+
if (images.length) await uploadImageFiles(images);
|
|
364
|
+
if (regularFiles.length) await uploadAttachmentFiles(regularFiles);
|
|
154
365
|
}
|
|
155
366
|
|
|
156
367
|
function openTimedSendPanel() {
|
|
157
|
-
closeComposerMenu();
|
|
158
368
|
if (isClaudeSession()) {
|
|
159
369
|
closeClaudePicker();
|
|
160
370
|
claudeResumePanelOpen = false;
|
|
@@ -187,43 +397,62 @@
|
|
|
187
397
|
function performSend() {
|
|
188
398
|
const val = inputEl.value;
|
|
189
399
|
const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
|
|
190
|
-
|
|
191
|
-
|
|
400
|
+
const readyFileAttachments = selectedFileAttachments.filter(item => !item.uploading);
|
|
401
|
+
if (selectedImageAttachments.some(item => item.uploading) || selectedFileAttachments.some(item => item.uploading)) {
|
|
402
|
+
alert('Wait for attachments to finish uploading before sending.');
|
|
192
403
|
return;
|
|
193
404
|
}
|
|
194
|
-
if ((val || readyImageAttachments.length) && isClaudeSession()) {
|
|
405
|
+
if ((val || readyImageAttachments.length || readyFileAttachments.length) && isClaudeSession()) {
|
|
195
406
|
if (currentSocket && currentSocket.readyState === 1) {
|
|
196
407
|
currentSocket.send(JSON.stringify({
|
|
197
408
|
type: 'claude-input',
|
|
198
409
|
text: val,
|
|
199
|
-
attachmentIds: readyImageAttachments.map(item => item.id)
|
|
410
|
+
attachmentIds: readyImageAttachments.map(item => item.id),
|
|
411
|
+
...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {})
|
|
200
412
|
}));
|
|
201
413
|
}
|
|
202
414
|
inputEl.value = '';
|
|
203
415
|
inputEl.style.height = '38px';
|
|
204
416
|
selectedImageAttachments = [];
|
|
205
|
-
|
|
417
|
+
selectedFileAttachments = [];
|
|
418
|
+
renderComposerAttachments();
|
|
206
419
|
return;
|
|
207
420
|
}
|
|
208
|
-
if ((val || readyImageAttachments.length) && isCodexSession()
|
|
421
|
+
if ((val || readyImageAttachments.length || readyFileAttachments.length) && isCodexSession()) {
|
|
209
422
|
if (!codexReadyForInput()) return;
|
|
210
423
|
if (currentSocket && currentSocket.readyState === 1) {
|
|
211
424
|
currentSocket.send(JSON.stringify({
|
|
212
425
|
type: 'codex-input',
|
|
213
426
|
text: val,
|
|
214
427
|
attachmentIds: readyImageAttachments.map(item => item.id),
|
|
428
|
+
...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {}),
|
|
215
429
|
skills: selectedCodexSkill ? [{ name: selectedCodexSkill.name, path: selectedCodexSkill.path }] : []
|
|
216
430
|
}));
|
|
217
431
|
}
|
|
218
432
|
inputEl.value = '';
|
|
219
433
|
inputEl.style.height = '38px';
|
|
220
434
|
selectedImageAttachments = [];
|
|
435
|
+
selectedFileAttachments = [];
|
|
221
436
|
selectedCodexSkill = null;
|
|
222
|
-
|
|
437
|
+
renderComposerAttachments();
|
|
223
438
|
renderCodexChat();
|
|
224
439
|
return;
|
|
225
440
|
}
|
|
226
|
-
if (val) {
|
|
441
|
+
if (val || readyFileAttachments.length) {
|
|
442
|
+
if (readyFileAttachments.length) {
|
|
443
|
+
if (currentSocket && currentSocket.readyState === 1) {
|
|
444
|
+
currentSocket.send(JSON.stringify({
|
|
445
|
+
type: 'file-input',
|
|
446
|
+
text: val,
|
|
447
|
+
fileAttachmentIds: readyFileAttachments.map(item => item.id)
|
|
448
|
+
}));
|
|
449
|
+
}
|
|
450
|
+
inputEl.value = '';
|
|
451
|
+
inputEl.style.height = '38px';
|
|
452
|
+
selectedFileAttachments = [];
|
|
453
|
+
renderComposerAttachments();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
227
456
|
const formattedVal = val.replace(/\n/g, '\r');
|
|
228
457
|
sendWS(formattedVal);
|
|
229
458
|
inputEl.value = '';
|
package/lib/web/core.js
CHANGED
|
@@ -9,6 +9,22 @@
|
|
|
9
9
|
let timedSendRefreshTimer = null;
|
|
10
10
|
let timedTagTimer = null;
|
|
11
11
|
let editingTimedInputId = null;
|
|
12
|
+
function setActionButtonLabel(button, label) {
|
|
13
|
+
if (!button) return;
|
|
14
|
+
const labelNode = button.querySelector('.action-label');
|
|
15
|
+
if (labelNode) {
|
|
16
|
+
labelNode.textContent = label;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
button.textContent = label;
|
|
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
|
+
}
|
|
12
28
|
let claudeMessages = [];
|
|
13
29
|
let claudePendingPermissions = [];
|
|
14
30
|
let claudeStatus = 'idle';
|
|
@@ -35,9 +51,8 @@
|
|
|
35
51
|
permissionMode: 'default', sandboxMode: 'default',
|
|
36
52
|
effectivePermissionMode: null, effectiveSandboxMode: null,
|
|
37
53
|
model: null, effort: null, status: 'idle', threadId: null,
|
|
38
|
-
|
|
39
|
-
canAbort: false, canCompact: false, compacting: false
|
|
40
|
-
canSwitchToTerminal: false, canSwitchToStructured: false
|
|
54
|
+
models: [], aborting: false, resuming: false,
|
|
55
|
+
canAbort: false, canCompact: false, compacting: false
|
|
41
56
|
};
|
|
42
57
|
}
|
|
43
58
|
let codexMessages = [];
|
|
@@ -235,7 +250,7 @@
|
|
|
235
250
|
${timedInputCount}
|
|
236
251
|
</span>`
|
|
237
252
|
: '';
|
|
238
|
-
html += `<div class="session-card">
|
|
253
|
+
html += `<div class="session-card${s.id === activeSessionId ? ' selected' : ''}" data-session-id="${escapeHtml(s.id)}">
|
|
239
254
|
<div class="session-info">
|
|
240
255
|
<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>
|
|
241
256
|
<p>${escapeHtml(s.tool)}</p>
|
|
@@ -316,11 +331,12 @@
|
|
|
316
331
|
}
|
|
317
332
|
|
|
318
333
|
function closeToolModal(e) {
|
|
319
|
-
if (e.target.id === 'modal-overlay') document.getElementById('modal-overlay').style.display = 'none';
|
|
334
|
+
if (!e || e.target.id === 'modal-overlay') document.getElementById('modal-overlay').style.display = 'none';
|
|
320
335
|
}
|
|
321
336
|
|
|
322
337
|
function isLobbyVisible() {
|
|
323
|
-
return document.getElementById('lobby-view').classList.contains('active')
|
|
338
|
+
return document.getElementById('lobby-view').classList.contains('active')
|
|
339
|
+
|| (typeof isSplitLayout === 'function' && isSplitLayout());
|
|
324
340
|
}
|
|
325
341
|
|
|
326
342
|
function scheduleSessionPolling() {
|