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.
@@ -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, env: { ...process.env }, stdio: ['pipe', 'pipe', 'pipe']
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
- if (!prompt || this.presentation !== 'structured' || this.status !== 'idle') return false;
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({ kind: 'user', text: prompt });
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 params = { threadId: this.threadId, input: [{ type: 'text', text: prompt }], cwd: this.workingDir, summary: 'auto' };
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;
@@ -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.write(sessionId, payload.text || '');
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 {
@@ -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="Schedule send">
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) currentSocket.send(JSON.stringify({ type: 'codex-input', text: val }));
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 panel = document.getElementById('timed-send-panel');
2875
- const isOpen = panel.classList.toggle('active');
2876
- document.getElementById('timer-btn').classList.toggle('active', isOpen);
2877
- if (isOpen) {
2878
- if (isClaudeSession()) {
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') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "description": "Glad transforms terminal-based AI coding tools into a polished, mobile-friendly local Web interface.",
5
5
  "main": "index.js",
6
6
  "bin": {