glad-web 1.0.24 → 1.0.25
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/codex/structured-session.js +31 -7
- package/lib/commands/web.js +47 -1
- package/lib/session/session-manager.js +200 -0
- package/lib/web/index.html +231 -24
- package/package.json +1 -1
|
@@ -43,6 +43,19 @@ function safeJson(value) {
|
|
|
43
43
|
try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
|
|
47
|
+
const options = { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] };
|
|
48
|
+
|
|
49
|
+
// Globally installed npm CLIs expose a .cmd shim on Windows. child_process.spawn
|
|
50
|
+
// does not resolve that shim without a shell, causing `spawn codex ENOENT`.
|
|
51
|
+
if (platform === 'win32') {
|
|
52
|
+
options.shell = true;
|
|
53
|
+
options.windowsHide = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return options;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
function textFromInputItems(content) {
|
|
47
60
|
return (Array.isArray(content) ? content : [])
|
|
48
61
|
.filter(item => item && item.type === 'text')
|
|
@@ -222,9 +235,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
222
235
|
async ensureProcess() {
|
|
223
236
|
if (this.processReady) return this.processReady;
|
|
224
237
|
this.processReady = new Promise((resolve, reject) => {
|
|
225
|
-
const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], {
|
|
226
|
-
cwd: this.workingDir,
|
|
227
|
-
|
|
238
|
+
const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], appServerSpawnOptions({
|
|
239
|
+
cwd: this.workingDir,
|
|
240
|
+
env: { ...process.env }
|
|
241
|
+
}));
|
|
228
242
|
this.process = child;
|
|
229
243
|
const fail = error => {
|
|
230
244
|
this.processReady = null;
|
|
@@ -583,11 +597,17 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
583
597
|
return this.getControlState();
|
|
584
598
|
}
|
|
585
599
|
|
|
586
|
-
async sendUserMessage(text) {
|
|
600
|
+
async sendUserMessage(text, attachments = []) {
|
|
587
601
|
const prompt = String(text || '').trim();
|
|
588
|
-
|
|
602
|
+
const images = (Array.isArray(attachments) ? attachments : [])
|
|
603
|
+
.filter(item => item && typeof item.path === 'string' && item.path);
|
|
604
|
+
if ((!prompt && images.length === 0) || this.presentation !== 'structured' || this.status !== 'idle') return false;
|
|
589
605
|
this.hasUnreadCompletion = false;
|
|
590
|
-
this.append({
|
|
606
|
+
this.append({
|
|
607
|
+
kind: 'user',
|
|
608
|
+
text: prompt || '📷 Image attachment',
|
|
609
|
+
attachments: images.map(item => ({ id: item.id, name: item.name || 'image' }))
|
|
610
|
+
});
|
|
591
611
|
await this.ensureProcess();
|
|
592
612
|
if (!this.threadId) {
|
|
593
613
|
const params = { cwd: this.workingDir };
|
|
@@ -603,7 +623,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
603
623
|
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
604
624
|
}
|
|
605
625
|
this.setStatus('running');
|
|
606
|
-
const
|
|
626
|
+
const input = [];
|
|
627
|
+
if (prompt) input.push({ type: 'text', text: prompt });
|
|
628
|
+
for (const image of images) input.push({ type: 'localImage', path: image.path });
|
|
629
|
+
const params = { threadId: this.threadId, input, cwd: this.workingDir, summary: 'auto' };
|
|
607
630
|
if (this.hasModelOverride) params.model = this.model;
|
|
608
631
|
if (this.hasEffortOverride) params.effort = this.effort;
|
|
609
632
|
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
@@ -760,3 +783,4 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
760
783
|
}
|
|
761
784
|
|
|
762
785
|
module.exports = CodexStructuredSession;
|
|
786
|
+
module.exports.appServerSpawnOptions = appServerSpawnOptions;
|
package/lib/commands/web.js
CHANGED
|
@@ -266,6 +266,51 @@ async function webCommand(options) {
|
|
|
266
266
|
res.json({ success: true });
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
// Browser images are stored only in a private, per-session temporary directory.
|
|
270
|
+
// Codex receives the resulting local path through its app-server protocol.
|
|
271
|
+
app.post('/api/sessions/:id/attachments/images', express.raw({ type: () => true, limit: '50mb' }), async (req, res) => {
|
|
272
|
+
try {
|
|
273
|
+
const attachment = await sessionManager.storeCodexImageAttachment(req.params.id, req.body);
|
|
274
|
+
res.status(201).json({ success: true, attachment });
|
|
275
|
+
} catch (e) {
|
|
276
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Mobile Safari can coalesce progress events for a single large request.
|
|
281
|
+
// Small sequential chunks let the browser report progress from server receipts.
|
|
282
|
+
app.post('/api/sessions/:id/attachments/images/chunks', express.raw({ type: () => true, limit: '1mb' }), async (req, res) => {
|
|
283
|
+
try {
|
|
284
|
+
const result = await sessionManager.appendCodexImageChunk(req.params.id, {
|
|
285
|
+
uploadId: req.get('X-Glad-Upload-Id'),
|
|
286
|
+
chunkIndex: req.get('X-Glad-Chunk-Index'),
|
|
287
|
+
chunkTotal: req.get('X-Glad-Chunk-Total')
|
|
288
|
+
}, req.body);
|
|
289
|
+
res.json({ success: true, ...result });
|
|
290
|
+
} catch (e) {
|
|
291
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
app.delete('/api/sessions/:id/attachments/images/uploads/:uploadId', async (req, res) => {
|
|
296
|
+
try {
|
|
297
|
+
const removed = await sessionManager.discardCodexImageUpload(req.params.id, req.params.uploadId);
|
|
298
|
+
res.json({ success: true, removed });
|
|
299
|
+
} catch (e) {
|
|
300
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
app.delete('/api/sessions/:id/attachments/images/:attachmentId', async (req, res) => {
|
|
305
|
+
try {
|
|
306
|
+
const removed = await sessionManager.discardCodexImageAttachment(req.params.id, req.params.attachmentId);
|
|
307
|
+
if (!removed) return res.status(404).json({ error: 'Image attachment not found' });
|
|
308
|
+
res.json({ success: true });
|
|
309
|
+
} catch (e) {
|
|
310
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
269
314
|
// API: Delete/Kill session
|
|
270
315
|
app.delete('/api/sessions/:id', (req, res) => {
|
|
271
316
|
sessionManager.kill(req.params.id);
|
|
@@ -514,7 +559,8 @@ async function webCommand(options) {
|
|
|
514
559
|
sessionManager.abortClaude(sessionId);
|
|
515
560
|
}
|
|
516
561
|
if (payload.type === 'codex-input') {
|
|
517
|
-
sessionManager.
|
|
562
|
+
sessionManager.sendCodexInput(sessionId, payload.text || '', payload.attachmentIds || [])
|
|
563
|
+
.catch(error => logger.error(`Codex input error: ${error.message}`));
|
|
518
564
|
}
|
|
519
565
|
if (payload.type === 'codex-permission') {
|
|
520
566
|
const codex = sessionManager.get(sessionId);
|
|
@@ -21,6 +21,30 @@ function previewText(text, maxChars = 320) {
|
|
|
21
21
|
return normalized.length > maxChars ? normalized.slice(-maxChars) : normalized;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const CODEX_IMAGE_MAX_BYTES = 50 * 1024 * 1024;
|
|
25
|
+
const CODEX_IMAGE_MAX_PER_SESSION = 5;
|
|
26
|
+
const CODEX_IMAGE_CLEANUP_DELAY_MS = 5 * 60 * 1000;
|
|
27
|
+
const CODEX_IMAGE_MAX_CHUNKS = 128;
|
|
28
|
+
|
|
29
|
+
function detectImageExtension(buffer) {
|
|
30
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null;
|
|
31
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'png';
|
|
32
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg';
|
|
33
|
+
if (buffer.subarray(0, 6).equals(Buffer.from('GIF87a')) || buffer.subarray(0, 6).equals(Buffer.from('GIF89a'))) return 'gif';
|
|
34
|
+
if (buffer.subarray(0, 4).equals(Buffer.from('RIFF')) && buffer.subarray(8, 12).equals(Buffer.from('WEBP'))) return 'webp';
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function attachmentError(message, statusCode = 400) {
|
|
39
|
+
const error = new Error(message);
|
|
40
|
+
error.statusCode = statusCode;
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isSafeUploadId(value) {
|
|
45
|
+
return typeof value === 'string' && /^[a-zA-Z0-9-]{8,100}$/.test(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
class SessionManager extends EventEmitter {
|
|
25
49
|
constructor({ baseDir, renderHistoryTools, debugHistoryEnabled = false, logger, hasConnectedSessionClient } = {}) {
|
|
26
50
|
super();
|
|
@@ -30,6 +54,8 @@ class SessionManager extends EventEmitter {
|
|
|
30
54
|
this.logger = logger || console;
|
|
31
55
|
this.hasConnectedSessionClient = hasConnectedSessionClient || (() => false);
|
|
32
56
|
this.sessions = new Map();
|
|
57
|
+
this.codexImageRoot = path.join(os.tmpdir(), 'glad', 'codex-images');
|
|
58
|
+
this.codexImageUploadRoot = path.join(os.tmpdir(), 'glad', 'codex-image-uploads');
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
list() {
|
|
@@ -177,6 +203,8 @@ class SessionManager extends EventEmitter {
|
|
|
177
203
|
}
|
|
178
204
|
const id = uuidv4();
|
|
179
205
|
const session = new CodexStructuredSession({ id, tool, workingDir: sessionDir, name: name || tool.displayName, logger: this.logger, options: codexOptions });
|
|
206
|
+
session.imageAttachments = new Map();
|
|
207
|
+
session.imageUploads = new Map();
|
|
180
208
|
this.sessions.set(id, session);
|
|
181
209
|
session.on('event', event => this.emit('codex-event', { sessionId: id, event, session }));
|
|
182
210
|
session.on('output', data => this.emit('output', { sessionId: id, data, session }));
|
|
@@ -203,6 +231,155 @@ class SessionManager extends EventEmitter {
|
|
|
203
231
|
return session.sendUserMessage(text);
|
|
204
232
|
}
|
|
205
233
|
|
|
234
|
+
async storeCodexImageAttachment(id, bytes) {
|
|
235
|
+
const session = this.get(id);
|
|
236
|
+
if (!session) throw attachmentError('Session not found', 404);
|
|
237
|
+
if (session.kind !== 'codex-structured' || session.presentation !== 'structured') {
|
|
238
|
+
throw attachmentError('Image attachments are available only in Codex chat mode');
|
|
239
|
+
}
|
|
240
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw attachmentError('Image data is required');
|
|
241
|
+
if (bytes.length > CODEX_IMAGE_MAX_BYTES) throw attachmentError('Image must be 50 MB or smaller');
|
|
242
|
+
if (session.imageAttachments.size >= CODEX_IMAGE_MAX_PER_SESSION) {
|
|
243
|
+
throw attachmentError(`You can attach at most ${CODEX_IMAGE_MAX_PER_SESSION} images at a time`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const extension = detectImageExtension(bytes);
|
|
247
|
+
if (!extension) throw attachmentError('Only PNG, JPEG, GIF, and WebP images are supported');
|
|
248
|
+
|
|
249
|
+
const directory = path.join(this.codexImageRoot, session.id);
|
|
250
|
+
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
251
|
+
const attachment = {
|
|
252
|
+
id: uuidv4(),
|
|
253
|
+
name: `image.${extension}`,
|
|
254
|
+
path: path.join(directory, `${uuidv4()}.${extension}`),
|
|
255
|
+
size: bytes.length,
|
|
256
|
+
createdAt: Date.now(),
|
|
257
|
+
cleanupTimer: null
|
|
258
|
+
};
|
|
259
|
+
await fs.promises.writeFile(attachment.path, bytes, { mode: 0o600, flag: 'wx' });
|
|
260
|
+
session.imageAttachments.set(attachment.id, attachment);
|
|
261
|
+
return { id: attachment.id, name: attachment.name, size: attachment.size };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async appendCodexImageChunk(id, input = {}, bytes) {
|
|
265
|
+
const session = this.get(id);
|
|
266
|
+
if (!session) throw attachmentError('Session not found', 404);
|
|
267
|
+
if (session.kind !== 'codex-structured' || session.presentation !== 'structured') {
|
|
268
|
+
throw attachmentError('Image attachments are available only in Codex chat mode');
|
|
269
|
+
}
|
|
270
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw attachmentError('Image chunk is required');
|
|
271
|
+
const uploadId = String(input.uploadId || '');
|
|
272
|
+
const chunkIndex = Number(input.chunkIndex);
|
|
273
|
+
const chunkTotal = Number(input.chunkTotal);
|
|
274
|
+
if (!isSafeUploadId(uploadId)) throw attachmentError('Invalid image upload id');
|
|
275
|
+
if (!Number.isInteger(chunkIndex) || !Number.isInteger(chunkTotal) || chunkIndex < 0 || chunkTotal < 1 || chunkTotal > CODEX_IMAGE_MAX_CHUNKS || chunkIndex >= chunkTotal) {
|
|
276
|
+
throw attachmentError('Invalid image chunk metadata');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let upload = session.imageUploads.get(uploadId);
|
|
280
|
+
if (!upload) {
|
|
281
|
+
if (chunkIndex !== 0) throw attachmentError('Image upload must start with the first chunk');
|
|
282
|
+
const directory = path.join(this.codexImageUploadRoot, session.id);
|
|
283
|
+
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
284
|
+
upload = {
|
|
285
|
+
id: uploadId,
|
|
286
|
+
path: path.join(directory, `${uploadId}.part`),
|
|
287
|
+
chunkTotal,
|
|
288
|
+
nextChunkIndex: 0,
|
|
289
|
+
bytes: 0
|
|
290
|
+
};
|
|
291
|
+
session.imageUploads.set(uploadId, upload);
|
|
292
|
+
}
|
|
293
|
+
if (upload.chunkTotal !== chunkTotal || upload.nextChunkIndex !== chunkIndex) {
|
|
294
|
+
throw attachmentError('Image chunks arrived out of order');
|
|
295
|
+
}
|
|
296
|
+
if (upload.bytes + bytes.length > CODEX_IMAGE_MAX_BYTES) {
|
|
297
|
+
await this.discardCodexImageUpload(id, uploadId);
|
|
298
|
+
throw attachmentError('Image must be 50 MB or smaller');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (chunkIndex === 0) await fs.promises.writeFile(upload.path, bytes, { mode: 0o600, flag: 'wx' });
|
|
302
|
+
else await fs.promises.appendFile(upload.path, bytes, { mode: 0o600 });
|
|
303
|
+
upload.bytes += bytes.length;
|
|
304
|
+
upload.nextChunkIndex += 1;
|
|
305
|
+
|
|
306
|
+
if (upload.nextChunkIndex < upload.chunkTotal) {
|
|
307
|
+
return { complete: false, receivedChunks: upload.nextChunkIndex, size: upload.bytes };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
session.imageUploads.delete(uploadId);
|
|
311
|
+
try {
|
|
312
|
+
const image = await fs.promises.readFile(upload.path);
|
|
313
|
+
const attachment = await this.storeCodexImageAttachment(id, image);
|
|
314
|
+
return { complete: true, attachment };
|
|
315
|
+
} finally {
|
|
316
|
+
await fs.promises.rm(upload.path, { force: true });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async discardCodexImageUpload(id, uploadId) {
|
|
321
|
+
const session = this.get(id);
|
|
322
|
+
if (!session || !session.imageUploads || !isSafeUploadId(uploadId)) return false;
|
|
323
|
+
const upload = session.imageUploads.get(uploadId);
|
|
324
|
+
if (!upload) return false;
|
|
325
|
+
session.imageUploads.delete(uploadId);
|
|
326
|
+
await fs.promises.rm(upload.path, { force: true });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async discardCodexImageAttachment(id, attachmentId) {
|
|
331
|
+
const session = this.get(id);
|
|
332
|
+
if (!session || session.kind !== 'codex-structured') return false;
|
|
333
|
+
const attachment = session.imageAttachments.get(attachmentId);
|
|
334
|
+
if (!attachment) return false;
|
|
335
|
+
clearTimeout(attachment.cleanupTimer);
|
|
336
|
+
session.imageAttachments.delete(attachmentId);
|
|
337
|
+
await fs.promises.rm(attachment.path, { force: true });
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
getCodexImageAttachments(id, attachmentIds = []) {
|
|
342
|
+
const session = this.get(id);
|
|
343
|
+
if (!session || session.kind !== 'codex-structured') throw attachmentError('Codex session not found', 404);
|
|
344
|
+
const ids = Array.isArray(attachmentIds) ? attachmentIds : [];
|
|
345
|
+
if (ids.length > CODEX_IMAGE_MAX_PER_SESSION) throw attachmentError(`You can attach at most ${CODEX_IMAGE_MAX_PER_SESSION} images at a time`);
|
|
346
|
+
const uniqueIds = [...new Set(ids.map(value => String(value)))];
|
|
347
|
+
if (uniqueIds.length !== ids.length) throw attachmentError('Duplicate image attachment');
|
|
348
|
+
return uniqueIds.map(attachmentId => {
|
|
349
|
+
const attachment = session.imageAttachments.get(attachmentId);
|
|
350
|
+
if (!attachment) throw attachmentError('Image attachment is no longer available');
|
|
351
|
+
return attachment;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
scheduleCodexImageCleanup(id, attachmentIds) {
|
|
356
|
+
const session = this.get(id);
|
|
357
|
+
if (!session || session.kind !== 'codex-structured') return;
|
|
358
|
+
for (const attachmentId of attachmentIds) {
|
|
359
|
+
const attachment = session.imageAttachments.get(attachmentId);
|
|
360
|
+
if (!attachment) continue;
|
|
361
|
+
clearTimeout(attachment.cleanupTimer);
|
|
362
|
+
attachment.cleanupTimer = setTimeout(() => {
|
|
363
|
+
this.discardCodexImageAttachment(id, attachmentId).catch(error => {
|
|
364
|
+
this.logger.debugInfo?.(`[codex-image] cleanup failed: ${error.message}`);
|
|
365
|
+
});
|
|
366
|
+
}, CODEX_IMAGE_CLEANUP_DELAY_MS);
|
|
367
|
+
attachment.cleanupTimer.unref?.();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async sendCodexInput(id, text, attachmentIds = []) {
|
|
372
|
+
const session = this.get(id);
|
|
373
|
+
if (!session || session.kind !== 'codex-structured') return false;
|
|
374
|
+
const attachments = this.getCodexImageAttachments(id, attachmentIds);
|
|
375
|
+
const prompt = String(text || '');
|
|
376
|
+
if (!prompt.trim() && attachments.length === 0) return false;
|
|
377
|
+
this.markSessionInput(session, prompt || '[image attachment]');
|
|
378
|
+
const sent = await session.sendUserMessage(prompt, attachments);
|
|
379
|
+
if (sent && attachments.length) this.scheduleCodexImageCleanup(id, attachments.map(item => item.id));
|
|
380
|
+
return sent;
|
|
381
|
+
}
|
|
382
|
+
|
|
206
383
|
respondClaudePermission(id, permissionId, approved, action = null) {
|
|
207
384
|
const session = this.get(id);
|
|
208
385
|
if (!session || session.kind !== 'claude-structured') return false;
|
|
@@ -438,6 +615,8 @@ class SessionManager extends EventEmitter {
|
|
|
438
615
|
if (!session) return false;
|
|
439
616
|
clearTimeout(session.completionTimer);
|
|
440
617
|
this.clearTimedInputs(session);
|
|
618
|
+
this.clearCodexImageUploads(session);
|
|
619
|
+
this.clearCodexImageAttachments(session);
|
|
441
620
|
this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
|
|
442
621
|
this.disposeSessionHistory(session);
|
|
443
622
|
if (['claude-structured', 'codex-structured'].includes(session.kind)) {
|
|
@@ -454,6 +633,8 @@ class SessionManager extends EventEmitter {
|
|
|
454
633
|
for (const session of this.sessions.values()) {
|
|
455
634
|
clearTimeout(session.completionTimer);
|
|
456
635
|
this.clearTimedInputs(session);
|
|
636
|
+
this.clearCodexImageUploads(session);
|
|
637
|
+
this.clearCodexImageAttachments(session);
|
|
457
638
|
this.disposeSessionHistory(session);
|
|
458
639
|
session.ptyManager.kill();
|
|
459
640
|
}
|
|
@@ -802,6 +983,8 @@ class SessionManager extends EventEmitter {
|
|
|
802
983
|
this.logger.info(`Session ${session.id} (${session.name}) exited.`);
|
|
803
984
|
clearTimeout(session.completionTimer);
|
|
804
985
|
this.clearTimedInputs(session);
|
|
986
|
+
this.clearCodexImageUploads(session);
|
|
987
|
+
this.clearCodexImageAttachments(session);
|
|
805
988
|
this.disposeSessionHistory(session);
|
|
806
989
|
this.sessions.delete(session.id);
|
|
807
990
|
this.emit('exit', { sessionId: session.id, session });
|
|
@@ -844,6 +1027,23 @@ class SessionManager extends EventEmitter {
|
|
|
844
1027
|
session.timedInputs.clear();
|
|
845
1028
|
}
|
|
846
1029
|
|
|
1030
|
+
clearCodexImageAttachments(session) {
|
|
1031
|
+
if (!session || !session.imageAttachments) return;
|
|
1032
|
+
for (const attachment of session.imageAttachments.values()) clearTimeout(attachment.cleanupTimer);
|
|
1033
|
+
session.imageAttachments.clear();
|
|
1034
|
+
fs.promises.rm(path.join(this.codexImageRoot, session.id), { recursive: true, force: true }).catch(error => {
|
|
1035
|
+
this.logger.debugInfo?.(`[codex-image] session cleanup failed: ${error.message}`);
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
clearCodexImageUploads(session) {
|
|
1040
|
+
if (!session || !session.imageUploads) return;
|
|
1041
|
+
session.imageUploads.clear();
|
|
1042
|
+
fs.promises.rm(path.join(this.codexImageUploadRoot, session.id), { recursive: true, force: true }).catch(error => {
|
|
1043
|
+
this.logger.debugInfo?.(`[codex-image] upload cleanup failed: ${error.message}`);
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
847
1047
|
getSessionDiagnostics(session, extra = {}) {
|
|
848
1048
|
if (['claude-structured', 'codex-structured'].includes(session.kind)) {
|
|
849
1049
|
return {
|
package/lib/web/index.html
CHANGED
|
@@ -52,9 +52,26 @@
|
|
|
52
52
|
#cmd-input { flex: 1; min-height: 38px; max-height: 150px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #fff; padding: 9px 16px; font-size: 16px; outline: none; resize: none; overflow-y: auto; line-height: 20px; box-sizing: border-box; transition: background 0.18s ease, border-color 0.18s ease; }
|
|
53
53
|
#cmd-input::placeholder { color: rgba(255,255,255,0.45); }
|
|
54
54
|
#cmd-input:focus { background: rgba(255,255,255,0.1); border-color: rgba(0,122,255,0.42); color: #fff; }
|
|
55
|
-
#timer-btn { width: 38px; height: 38px; margin-left: 8px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #d1d5db; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
|
|
55
|
+
#timer-btn { width: 38px; height: 38px; margin-left: 8px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #d1d5db; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; font-size: 24px; line-height: 1; }
|
|
56
56
|
#timer-btn.active { color: #fff; border-color: rgba(0,122,255,0.45); background: rgba(0,122,255,0.22); }
|
|
57
57
|
#send-btn { width: 44px; height: 38px; margin-left: 10px; background: #007aff; border: none; border-radius: 19px; color: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
|
|
58
|
+
#composer-menu { display: none; width: min(calc(100% - 28px), var(--control-content-max)); margin: -2px auto 8px auto; padding: 6px; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(38,38,42,0.98); box-shadow: 0 12px 28px rgba(0,0,0,0.28); box-sizing: border-box; }
|
|
59
|
+
#composer-menu.active { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
|
60
|
+
.composer-menu-btn { min-height: 42px; border: 0; border-radius: 9px; background: rgba(255,255,255,0.08); color: #fff; font-size: 14px; font-weight: 650; cursor: pointer; }
|
|
61
|
+
.composer-menu-btn:hover { background: rgba(255,255,255,0.14); }
|
|
62
|
+
#attachment-strip { display: none; width: min(calc(100% - 28px), var(--control-content-max)); margin: -3px auto 2px auto; gap: 7px; overflow-x: auto; padding: 0 0 4px 0; box-sizing: border-box; scrollbar-width: none; }
|
|
63
|
+
#attachment-strip.active { display: flex; }
|
|
64
|
+
#attachment-strip::-webkit-scrollbar { display: none; }
|
|
65
|
+
.attachment-chip { min-width: 0; max-width: 210px; display: flex; align-items: center; gap: 7px; padding: 7px 9px; border: 1px solid rgba(0,122,255,0.45); border-radius: 10px; background: rgba(0,122,255,0.14); color: #e9f2ff; font-size: 12px; }
|
|
66
|
+
.attachment-chip.uploading { border-color: rgba(255,159,10,0.55); background: rgba(255,159,10,0.12); }
|
|
67
|
+
.attachment-chip-content { min-width: 0; flex: 1; }
|
|
68
|
+
.attachment-chip-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
69
|
+
.attachment-progress { display: block; height: 5px; margin-top: 5px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,0.16); }
|
|
70
|
+
.attachment-progress > span { display: block; height: 100%; border-radius: inherit; background: #ff9f0a; transition: width .15s ease; }
|
|
71
|
+
.attachment-progress.estimated > span { background: repeating-linear-gradient(90deg, #ff9f0a 0 12px, #ffd37b 12px 24px); background-size: 48px 100%; animation: attachment-upload-pulse .75s linear infinite; }
|
|
72
|
+
.attachment-status { display: block; margin-top: 3px; color: #ffd59a; font-size: 10px; }
|
|
73
|
+
@keyframes attachment-upload-pulse { from { background-position: 0 0; } to { background-position: 48px 0; } }
|
|
74
|
+
.attachment-remove { width: 19px; height: 19px; padding: 0; border: 0; border-radius: 50%; background: rgba(255,255,255,0.16); color: #fff; font-size: 15px; line-height: 18px; cursor: pointer; flex: 0 0 auto; }
|
|
58
75
|
#timed-send-panel { display: none; margin: 0 14px 10px 14px; padding: 12px; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; background: rgba(28,28,30,0.98); box-sizing: border-box; }
|
|
59
76
|
#timed-send-panel.active { display: block; }
|
|
60
77
|
.timed-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: center; }
|
|
@@ -376,13 +393,17 @@
|
|
|
376
393
|
<div id="timed-tag-rail"></div>
|
|
377
394
|
<div id="input-row">
|
|
378
395
|
<textarea id="cmd-input" rows="1" placeholder="Type a message..."></textarea>
|
|
379
|
-
<button id="timer-btn" title="
|
|
380
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
|
|
381
|
-
</button>
|
|
396
|
+
<button id="timer-btn" title="Add image or schedule send" aria-label="Add image or schedule send">+</button>
|
|
382
397
|
<button id="send-btn">
|
|
383
398
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
|
384
399
|
</button>
|
|
385
400
|
</div>
|
|
401
|
+
<div id="attachment-strip" aria-live="polite"></div>
|
|
402
|
+
<div id="composer-menu" role="menu">
|
|
403
|
+
<button id="attach-image-btn" class="composer-menu-btn" type="button" role="menuitem">Add image</button>
|
|
404
|
+
<button id="schedule-send-btn" class="composer-menu-btn" type="button" role="menuitem">Schedule send</button>
|
|
405
|
+
</div>
|
|
406
|
+
<input id="image-file-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
|
|
386
407
|
<div id="claude-control-panel">
|
|
387
408
|
<div class="claude-control-row">
|
|
388
409
|
<div class="claude-picker-wrap">
|
|
@@ -942,6 +963,7 @@
|
|
|
942
963
|
document.getElementById('codex-control-panel').style.display = codexChat ? 'block' : 'none';
|
|
943
964
|
document.getElementById('codex-terminal-switch').style.display = isCodexSession() ? '' : 'none';
|
|
944
965
|
document.getElementById('timer-btn').style.display = '';
|
|
966
|
+
document.getElementById('attach-image-btn').style.display = codexChat ? '' : 'none';
|
|
945
967
|
document.getElementById('shortcut-rail').style.display = structured ? 'none' : '';
|
|
946
968
|
document.getElementById('scroll-controls').style.display = structured ? 'none' : '';
|
|
947
969
|
document.getElementById('cmd-input').placeholder = enabled ? 'Message Claude...' : codexChat ? 'Message Codex...' : 'Type a message...';
|
|
@@ -2312,6 +2334,9 @@
|
|
|
2312
2334
|
}
|
|
2313
2335
|
|
|
2314
2336
|
function joinSession(id, sessionName, toolKey = null) {
|
|
2337
|
+
if (typeof clearImageAttachments === 'function' && selectedImageAttachments.length) {
|
|
2338
|
+
void clearImageAttachments();
|
|
2339
|
+
}
|
|
2315
2340
|
stopTimedInputTimers();
|
|
2316
2341
|
activeSessionId = id;
|
|
2317
2342
|
window.activeSessionId = id;
|
|
@@ -2655,8 +2680,177 @@
|
|
|
2655
2680
|
}
|
|
2656
2681
|
|
|
2657
2682
|
const inputEl = document.getElementById('cmd-input');
|
|
2683
|
+
const imageFileInput = document.getElementById('image-file-input');
|
|
2684
|
+
const attachmentStrip = document.getElementById('attachment-strip');
|
|
2685
|
+
let selectedImageAttachments = [];
|
|
2658
2686
|
let keepTerminalBottomForNextInput = false;
|
|
2659
2687
|
|
|
2688
|
+
function isCodexImageAttachmentAvailable() {
|
|
2689
|
+
return isCodexSession() && codexState.presentation === 'structured';
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
function syncComposerButtonState() {
|
|
2693
|
+
const menuOpen = document.getElementById('composer-menu').classList.contains('active');
|
|
2694
|
+
const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
|
|
2695
|
+
document.getElementById('timer-btn').classList.toggle('active', menuOpen || timerOpen);
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
function renderImageAttachments() {
|
|
2699
|
+
attachmentStrip.innerHTML = selectedImageAttachments.map(item => (
|
|
2700
|
+
`<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>`
|
|
2701
|
+
)).join('');
|
|
2702
|
+
attachmentStrip.classList.toggle('active', selectedImageAttachments.length > 0);
|
|
2703
|
+
updateTerminalControlsHeight();
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
window.removeImageAttachment = async function(attachmentId) {
|
|
2707
|
+
const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
|
|
2708
|
+
selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
|
|
2709
|
+
renderImageAttachments();
|
|
2710
|
+
if (!attachment) return;
|
|
2711
|
+
clearInterval(attachment.indicatorTimer);
|
|
2712
|
+
attachment.abortUpload?.();
|
|
2713
|
+
if (attachment.uploading) return;
|
|
2714
|
+
try {
|
|
2715
|
+
await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
2716
|
+
} catch (_) {
|
|
2717
|
+
// The server also removes all attachments when the session ends.
|
|
2718
|
+
}
|
|
2719
|
+
};
|
|
2720
|
+
|
|
2721
|
+
async function clearImageAttachments() {
|
|
2722
|
+
const pending = selectedImageAttachments;
|
|
2723
|
+
selectedImageAttachments = [];
|
|
2724
|
+
renderImageAttachments();
|
|
2725
|
+
for (const item of pending) item.abortUpload?.();
|
|
2726
|
+
await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
|
|
2727
|
+
`/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
|
|
2728
|
+
{ method: 'DELETE' }
|
|
2729
|
+
).catch(() => null)));
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
const IMAGE_UPLOAD_CHUNK_BYTES = 512 * 1024;
|
|
2733
|
+
|
|
2734
|
+
function uploadImageInChunks(sessionId, file, onProgress) {
|
|
2735
|
+
let xhr = null;
|
|
2736
|
+
let cancelled = false;
|
|
2737
|
+
const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
2738
|
+
const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
|
|
2739
|
+
return {
|
|
2740
|
+
abort: () => {
|
|
2741
|
+
cancelled = true;
|
|
2742
|
+
xhr?.abort();
|
|
2743
|
+
void fetch(`/api/sessions/${sessionId}/attachments/images/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
|
|
2744
|
+
},
|
|
2745
|
+
promise: (async () => {
|
|
2746
|
+
for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
|
|
2747
|
+
if (cancelled) throw new Error('Image upload cancelled');
|
|
2748
|
+
const start = chunkIndex * IMAGE_UPLOAD_CHUNK_BYTES;
|
|
2749
|
+
const chunk = file.slice(start, Math.min(file.size, start + IMAGE_UPLOAD_CHUNK_BYTES));
|
|
2750
|
+
const result = await new Promise((resolve, reject) => {
|
|
2751
|
+
xhr = new XMLHttpRequest();
|
|
2752
|
+
xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
|
|
2753
|
+
xhr.timeout = 60_000;
|
|
2754
|
+
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
|
2755
|
+
xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
|
|
2756
|
+
xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
|
|
2757
|
+
xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
|
|
2758
|
+
xhr.onerror = () => reject(new Error('Network error while uploading image'));
|
|
2759
|
+
xhr.ontimeout = () => reject(new Error(`Image upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
|
|
2760
|
+
xhr.onabort = () => reject(new Error('Image upload cancelled'));
|
|
2761
|
+
xhr.onload = () => {
|
|
2762
|
+
let data = {};
|
|
2763
|
+
try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
|
|
2764
|
+
if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
|
|
2765
|
+
reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
resolve(data);
|
|
2769
|
+
};
|
|
2770
|
+
xhr.send(chunk);
|
|
2771
|
+
});
|
|
2772
|
+
const confirmedBytes = Math.min(file.size, start + chunk.size);
|
|
2773
|
+
onProgress(Math.round((confirmedBytes / file.size) * 100), chunkIndex + 1, chunkTotal);
|
|
2774
|
+
if (result.complete) return result.attachment;
|
|
2775
|
+
}
|
|
2776
|
+
throw new Error('Image upload did not complete');
|
|
2777
|
+
})()
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
async function uploadImageFiles(files) {
|
|
2782
|
+
if (!isCodexImageAttachmentAvailable()) {
|
|
2783
|
+
alert('Image attachments are available only in Codex chat mode.');
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
const remaining = 5 - selectedImageAttachments.length;
|
|
2787
|
+
const batch = Array.from(files).slice(0, remaining);
|
|
2788
|
+
if (files.length > remaining) alert('You can attach up to 5 images at a time.');
|
|
2789
|
+
for (const file of batch) {
|
|
2790
|
+
if (file.size > 50 * 1024 * 1024) {
|
|
2791
|
+
alert(`${file.name} is larger than 50 MB.`);
|
|
2792
|
+
continue;
|
|
2793
|
+
}
|
|
2794
|
+
const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
|
|
2795
|
+
const pending = {
|
|
2796
|
+
id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
|
|
2797
|
+
name: file.name || 'image',
|
|
2798
|
+
sessionId: activeSessionId,
|
|
2799
|
+
uploading: true,
|
|
2800
|
+
progress: 0,
|
|
2801
|
+
progressKnown: true,
|
|
2802
|
+
status: '0%',
|
|
2803
|
+
abortUpload: null
|
|
2804
|
+
};
|
|
2805
|
+
selectedImageAttachments.push(pending);
|
|
2806
|
+
renderImageAttachments();
|
|
2807
|
+
try {
|
|
2808
|
+
const upload = uploadImageInChunks(activeSessionId, file, progress => {
|
|
2809
|
+
pending.progress = progress;
|
|
2810
|
+
pending.status = `${progress}%`;
|
|
2811
|
+
renderImageAttachments();
|
|
2812
|
+
});
|
|
2813
|
+
pending.abortUpload = upload.abort;
|
|
2814
|
+
const attachment = await upload.promise;
|
|
2815
|
+
const index = selectedImageAttachments.indexOf(pending);
|
|
2816
|
+
if (index < 0) {
|
|
2817
|
+
await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
2818
|
+
continue;
|
|
2819
|
+
}
|
|
2820
|
+
selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
|
|
2821
|
+
renderImageAttachments();
|
|
2822
|
+
} catch (e) {
|
|
2823
|
+
selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
|
|
2824
|
+
renderImageAttachments();
|
|
2825
|
+
if (e.message === 'Image upload cancelled') continue;
|
|
2826
|
+
alert(`Could not add ${file.name}: ${e.message}`);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
function closeComposerMenu() {
|
|
2832
|
+
document.getElementById('composer-menu').classList.remove('active');
|
|
2833
|
+
syncComposerButtonState();
|
|
2834
|
+
updateTerminalControlsHeight();
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
function openTimedSendPanel() {
|
|
2838
|
+
closeComposerMenu();
|
|
2839
|
+
if (isClaudeSession()) {
|
|
2840
|
+
closeClaudePicker();
|
|
2841
|
+
closeClaudeUsagePanel();
|
|
2842
|
+
claudeResumePanelOpen = false;
|
|
2843
|
+
document.getElementById('claude-resume-panel').classList.remove('active');
|
|
2844
|
+
}
|
|
2845
|
+
initTimedDelaySelectors();
|
|
2846
|
+
resetTimedEditor({ keepInput: true });
|
|
2847
|
+
document.getElementById('timed-send-panel').classList.add('active');
|
|
2848
|
+
updateTimedSendPreview();
|
|
2849
|
+
loadTimedInputs();
|
|
2850
|
+
syncComposerButtonState();
|
|
2851
|
+
updateTerminalControlsHeight();
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2660
2854
|
function markInputEditStart() {
|
|
2661
2855
|
keepTerminalBottomForNextInput = keepTerminalBottomForNextInput || isTerminalAtBottom();
|
|
2662
2856
|
}
|
|
@@ -2672,6 +2866,11 @@
|
|
|
2672
2866
|
|
|
2673
2867
|
function performSend() {
|
|
2674
2868
|
const val = inputEl.value;
|
|
2869
|
+
const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
|
|
2870
|
+
if (selectedImageAttachments.some(item => item.uploading)) {
|
|
2871
|
+
alert('Wait for image uploads to finish before sending.');
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2675
2874
|
if (val && isClaudeSession()) {
|
|
2676
2875
|
if (currentSocket && currentSocket.readyState === 1) {
|
|
2677
2876
|
currentSocket.send(JSON.stringify({ type: 'claude-input', text: val }));
|
|
@@ -2680,10 +2879,18 @@
|
|
|
2680
2879
|
inputEl.style.height = '38px';
|
|
2681
2880
|
return;
|
|
2682
2881
|
}
|
|
2683
|
-
if (val && isCodexSession() && codexState.presentation === 'structured') {
|
|
2684
|
-
if (currentSocket && currentSocket.readyState === 1)
|
|
2882
|
+
if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
|
|
2883
|
+
if (currentSocket && currentSocket.readyState === 1) {
|
|
2884
|
+
currentSocket.send(JSON.stringify({
|
|
2885
|
+
type: 'codex-input',
|
|
2886
|
+
text: val,
|
|
2887
|
+
attachmentIds: readyImageAttachments.map(item => item.id)
|
|
2888
|
+
}));
|
|
2889
|
+
}
|
|
2685
2890
|
inputEl.value = '';
|
|
2686
2891
|
inputEl.style.height = '38px';
|
|
2892
|
+
selectedImageAttachments = [];
|
|
2893
|
+
renderImageAttachments();
|
|
2687
2894
|
return;
|
|
2688
2895
|
}
|
|
2689
2896
|
if (val) {
|
|
@@ -2762,9 +2969,9 @@
|
|
|
2762
2969
|
|
|
2763
2970
|
function closeTimedSendPanel() {
|
|
2764
2971
|
document.getElementById('timed-send-panel').classList.remove('active');
|
|
2765
|
-
document.getElementById('timer-btn').classList.remove('active');
|
|
2766
2972
|
editingTimedInputId = null;
|
|
2767
2973
|
renderTimedTags();
|
|
2974
|
+
syncComposerButtonState();
|
|
2768
2975
|
updateTerminalControlsHeight();
|
|
2769
2976
|
}
|
|
2770
2977
|
|
|
@@ -2813,6 +3020,7 @@
|
|
|
2813
3020
|
function editTimedInput(id) {
|
|
2814
3021
|
const item = timedInputs.find(value => value.id === id);
|
|
2815
3022
|
if (!item) return;
|
|
3023
|
+
closeComposerMenu();
|
|
2816
3024
|
initTimedDelaySelectors();
|
|
2817
3025
|
editingTimedInputId = id;
|
|
2818
3026
|
inputEl.value = item.text || '';
|
|
@@ -2823,9 +3031,9 @@
|
|
|
2823
3031
|
document.getElementById('timed-cancel-edit-btn').style.display = '';
|
|
2824
3032
|
document.getElementById('timed-delete-btn').style.display = '';
|
|
2825
3033
|
document.getElementById('timed-send-panel').classList.add('active');
|
|
2826
|
-
document.getElementById('timer-btn').classList.add('active');
|
|
2827
3034
|
updateTimedSendPreview();
|
|
2828
3035
|
renderTimedTags();
|
|
3036
|
+
syncComposerButtonState();
|
|
2829
3037
|
updateTerminalControlsHeight();
|
|
2830
3038
|
}
|
|
2831
3039
|
|
|
@@ -2871,24 +3079,23 @@
|
|
|
2871
3079
|
|
|
2872
3080
|
document.getElementById('send-btn').addEventListener('click', performSend);
|
|
2873
3081
|
document.getElementById('timer-btn').addEventListener('click', () => {
|
|
2874
|
-
const
|
|
2875
|
-
const
|
|
2876
|
-
document.getElementById('
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
closeClaudePicker();
|
|
2880
|
-
closeClaudeUsagePanel();
|
|
2881
|
-
claudeResumePanelOpen = false;
|
|
2882
|
-
document.getElementById('claude-resume-panel').classList.remove('active');
|
|
2883
|
-
}
|
|
2884
|
-
initTimedDelaySelectors();
|
|
2885
|
-
resetTimedEditor({ keepInput: true });
|
|
2886
|
-
updateTimedSendPreview();
|
|
2887
|
-
loadTimedInputs();
|
|
2888
|
-
}
|
|
2889
|
-
else closeTimedSendPanel();
|
|
3082
|
+
const menu = document.getElementById('composer-menu');
|
|
3083
|
+
const willOpen = !menu.classList.contains('active');
|
|
3084
|
+
document.getElementById('timed-send-panel').classList.remove('active');
|
|
3085
|
+
menu.classList.toggle('active', willOpen);
|
|
3086
|
+
syncComposerButtonState();
|
|
2890
3087
|
updateTerminalControlsHeight();
|
|
2891
3088
|
});
|
|
3089
|
+
document.getElementById('attach-image-btn').addEventListener('click', () => {
|
|
3090
|
+
closeComposerMenu();
|
|
3091
|
+
imageFileInput.click();
|
|
3092
|
+
});
|
|
3093
|
+
document.getElementById('schedule-send-btn').addEventListener('click', openTimedSendPanel);
|
|
3094
|
+
imageFileInput.addEventListener('change', () => {
|
|
3095
|
+
const files = imageFileInput.files;
|
|
3096
|
+
if (files?.length) void uploadImageFiles(files);
|
|
3097
|
+
imageFileInput.value = '';
|
|
3098
|
+
});
|
|
2892
3099
|
inputEl.addEventListener('keydown', (e) => {
|
|
2893
3100
|
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); performSend(); }
|
|
2894
3101
|
else if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete') {
|