glad-web 1.0.24 → 1.0.26

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')
@@ -50,6 +63,22 @@ function textFromInputItems(content) {
50
63
  .join('\n');
51
64
  }
52
65
 
66
+ function recentUserQuestions(thread, limit = 2) {
67
+ const questions = [];
68
+ const turns = Array.isArray(thread?.turns) ? thread.turns : [];
69
+ for (let turnIndex = turns.length - 1; turnIndex >= 0 && questions.length < limit; turnIndex -= 1) {
70
+ const items = Array.isArray(turns[turnIndex]?.items) ? turns[turnIndex].items : [];
71
+ for (let itemIndex = items.length - 1; itemIndex >= 0 && questions.length < limit; itemIndex -= 1) {
72
+ const item = items[itemIndex];
73
+ if (item?.type !== 'userMessage') continue;
74
+ const text = (textFromInputItems(item.content) || item.text || '').trim();
75
+ if (text) questions.push(text);
76
+ }
77
+ }
78
+ while (questions.length < limit) questions.push('');
79
+ return questions;
80
+ }
81
+
53
82
  function toolDetails(raw) {
54
83
  if (raw.type === 'commandExecution') {
55
84
  return {
@@ -85,13 +114,22 @@ function toolDetails(raw) {
85
114
  };
86
115
  }
87
116
  if (raw.type === 'collabAgentToolCall') {
117
+ const receiverThreadIds = Array.isArray(raw.receiverThreadIds) ? raw.receiverThreadIds.filter(Boolean) : [];
118
+ const input = raw.arguments || raw.input || {
119
+ ...(raw.prompt ? { prompt: raw.prompt } : {}),
120
+ ...(receiverThreadIds.length ? { receiverThreadIds } : {}),
121
+ ...(raw.agentsStates && Object.keys(raw.agentsStates).length ? { agentsStates: raw.agentsStates } : {})
122
+ };
88
123
  return {
89
124
  name: 'Agent',
90
125
  title: raw.tool || raw.action || 'Subagent',
91
- input: raw.arguments || raw.input || raw,
92
- result: raw.error != null ? String(raw.error) : safeJson(raw.result || ''),
126
+ tool: raw.tool || raw.action || 'subagent',
127
+ input,
128
+ result: raw.error != null ? String(raw.error) : raw.result == null ? '' : safeJson(raw.result),
93
129
  error: raw.error != null ? String(raw.error) : null,
94
- subagentId: raw.receiverThreadId || raw.agentId || raw.id || null
130
+ subagentId: raw.receiverThreadId || receiverThreadIds[0] || raw.agentId || null,
131
+ subagentIds: receiverThreadIds,
132
+ agentsStates: raw.agentsStates || {}
95
133
  };
96
134
  }
97
135
  return {
@@ -122,6 +160,7 @@ class CodexStructuredSession extends EventEmitter {
122
160
  this.threadId = options.resume || null;
123
161
  this.currentTurnId = null;
124
162
  this.currentTurnStartedAt = null;
163
+ this.threadTurns = new Map();
125
164
  this.tokenUsage = null;
126
165
  this.permissionMode = normalizePermissionMode(options.permissionMode);
127
166
  this.sandboxMode = normalizeSandboxMode(options.sandboxMode);
@@ -173,6 +212,8 @@ class CodexStructuredSession extends EventEmitter {
173
212
  }
174
213
 
175
214
  getControlState() {
215
+ const activeSubagentCount = Array.from(this.threadTurns.entries())
216
+ .filter(([threadId, turn]) => threadId !== this.threadId && turn?.status === 'running').length;
176
217
  return { permissionMode: this.permissionMode || 'default', sandboxMode: this.sandboxMode || 'default',
177
218
  effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
178
219
  model: this.model, effort: this.effort,
@@ -180,7 +221,7 @@ class CodexStructuredSession extends EventEmitter {
180
221
  canAbort: this.presentation === 'structured' && this.status !== 'idle',
181
222
  canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle' && Boolean(this.threadId),
182
223
  canSwitchToStructured: this.presentation === 'terminal',
183
- pendingPermissionCount: this.pendingPermissions.size, models: this.models };
224
+ pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
184
225
  }
185
226
 
186
227
  getHistory() {
@@ -212,6 +253,7 @@ class CodexStructuredSession extends EventEmitter {
212
253
  }
213
254
  emitEvent(event) { this.emit('event', event); }
214
255
  setStatus(status) { if (this.status !== status) { this.status = status; this.emitEvent({ type: 'state', state: this.getControlState() }); } }
256
+ emitControlState() { this.emitEvent({ type: 'state', state: this.getControlState() }); }
215
257
  recordPermission(request, status, decision) {
216
258
  const completed = { ...request, status, decision };
217
259
  this.completedPermissions = [...this.completedPermissions.filter(item => item.id !== request.id), completed].slice(-50);
@@ -222,9 +264,10 @@ class CodexStructuredSession extends EventEmitter {
222
264
  async ensureProcess() {
223
265
  if (this.processReady) return this.processReady;
224
266
  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
- });
267
+ const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], appServerSpawnOptions({
268
+ cwd: this.workingDir,
269
+ env: { ...process.env }
270
+ }));
228
271
  this.process = child;
229
272
  const fail = error => {
230
273
  this.processReady = null;
@@ -335,44 +378,75 @@ class CodexStructuredSession extends EventEmitter {
335
378
  return;
336
379
  }
337
380
  if (method === 'turn/started') {
338
- this.currentTurnId = params.turn?.id || params.turnId || this.currentTurnId;
339
- this.currentTurnStartedAt = Date.now();
340
- this.append({ kind: 'turn-start', turnId: this.currentTurnId });
341
- this.setStatus('running');
381
+ const threadId = params.threadId || this.threadId;
382
+ const turnId = params.turn?.id || params.turnId || null;
383
+ const startedAt = Number(params.turn?.startedAt || 0);
384
+ const startedAtMs = startedAt > 0 && startedAt < 100000000000 ? startedAt * 1000 : startedAt || Date.now();
385
+ if (threadId && turnId) this.threadTurns.set(threadId, { turnId, startedAt: startedAtMs, status: 'running' });
386
+ if (!threadId || threadId === this.threadId) {
387
+ this.currentTurnId = turnId || this.currentTurnId;
388
+ this.currentTurnStartedAt = startedAtMs;
389
+ this.setStatus('running');
390
+ } else {
391
+ this.emitControlState();
392
+ }
393
+ this.append({ kind: 'turn-start', threadId, turnId, createdAt: startedAtMs });
342
394
  return;
343
395
  }
344
396
  if (method === 'turn/completed') {
345
- const completedTurnId = params.turn?.id || params.turnId || this.currentTurnId;
397
+ const threadId = params.threadId || this.threadId;
398
+ const trackedTurn = threadId ? this.threadTurns.get(threadId) : null;
399
+ const completedTurnId = params.turn?.id || params.turnId || trackedTurn?.turnId || this.currentTurnId;
346
400
  const turnStatus = params.turn?.status === 'failed' || params.turn?.error ? 'failed'
347
401
  : params.turn?.status === 'interrupted' ? 'cancelled' : 'completed';
348
- this.append({ kind: 'turn-end', turnId: completedTurnId, status: turnStatus,
349
- durationMs: this.currentTurnStartedAt ? Date.now() - this.currentTurnStartedAt : null });
402
+ const completedAt = Number(params.turn?.completedAt || 0);
403
+ const completedAtMs = completedAt > 0 && completedAt < 100000000000 ? completedAt * 1000 : completedAt || Date.now();
404
+ const startedAtMs = trackedTurn?.startedAt || ((!threadId || threadId === this.threadId) ? this.currentTurnStartedAt : null);
405
+ const durationMs = Number(params.turn?.durationMs || 0)
406
+ || (startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null);
407
+ this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
408
+ durationMs, createdAt: completedAtMs });
409
+ const observedNow = Date.now();
410
+ const observedCompletedAtMs = Math.abs(observedNow - completedAtMs) < 5000
411
+ ? Math.max(completedAtMs, observedNow) : completedAtMs;
350
412
  for (const item of this.messages.filter(message => message.kind === 'tool'
351
413
  && message.turnId === completedTurnId && ['running', 'inProgress'].includes(message.toolStatus))) {
352
- this.patch(item.id, { toolStatus: turnStatus === 'failed' ? 'failed' : 'completed' });
353
- }
354
- for (const pending of this.pendingPermissions.values()) {
355
- this.recordPermission(pending.public, 'denied', 'abort');
414
+ const toolDurationMs = Number(item.durationMs || 0)
415
+ || (item.startedAtMs || item.createdAt ? Math.max(1, observedCompletedAtMs - Number(item.startedAtMs || item.createdAt)) : null);
416
+ const toolStatus = turnStatus === 'failed' ? 'failed' : turnStatus === 'cancelled' ? 'cancelled' : 'completed';
417
+ this.patch(item.id, { toolStatus,
418
+ completedAtMs: observedCompletedAtMs, ...(toolDurationMs != null ? { durationMs: toolDurationMs } : {}) });
356
419
  }
357
- this.currentTurnId = null;
358
- this.currentTurnStartedAt = null;
359
- this.pendingPermissions.clear();
360
- if (params.turn?.status === 'failed' || params.turn?.error) {
361
- this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
420
+ if (threadId) this.threadTurns.delete(threadId);
421
+ if (!threadId || threadId === this.threadId) {
422
+ for (const pending of this.pendingPermissions.values()) {
423
+ this.recordPermission(pending.public, 'denied', 'abort');
424
+ }
425
+ this.currentTurnId = null;
426
+ this.currentTurnStartedAt = null;
427
+ this.pendingPermissions.clear();
428
+ if (params.turn?.status === 'failed' || params.turn?.error) {
429
+ this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
430
+ }
431
+ this.setStatus('idle');
432
+ this.hasUnreadCompletion = true;
433
+ } else {
434
+ this.emitControlState();
362
435
  }
363
- this.setStatus('idle');
364
- this.hasUnreadCompletion = true;
365
436
  return;
366
437
  }
367
438
  if (method === 'thread/started' || method === 'thread/resumed') {
368
439
  const threadId = params.thread?.id || params.threadId;
369
- if (threadId) { this.threadId = threadId; this.emitEvent({ type: 'state', state: this.getControlState() }); }
440
+ if (threadId && !this.threadId) { this.threadId = threadId; this.emitControlState(); }
370
441
  return;
371
442
  }
372
443
  if (method === 'thread/status/changed') {
444
+ const threadId = params.threadId || this.threadId;
373
445
  const status = params.status?.type || params.status;
374
- if (status === 'idle') this.setStatus('idle');
375
- if (status === 'active') this.setStatus('running');
446
+ if (!threadId || threadId === this.threadId) {
447
+ if (status === 'idle' && !this.currentTurnId) this.setStatus('idle');
448
+ if (status === 'active') this.setStatus('running');
449
+ }
376
450
  return;
377
451
  }
378
452
  if (method === 'thread/settings/updated') {
@@ -416,11 +490,16 @@ class CodexStructuredSession extends EventEmitter {
416
490
  }
417
491
  if (method.startsWith('item/')) {
418
492
  const inferredStatus = method === 'item/completed' ? 'completed' : method === 'item/started' ? 'running' : null;
419
- this.applyProviderItem(params.item || params, inferredStatus);
493
+ this.applyProviderItem(params.item || params, inferredStatus, {
494
+ threadId: params.threadId || null,
495
+ turnId: params.turnId || null,
496
+ startedAtMs: Number(params.startedAtMs || 0) || null,
497
+ completedAtMs: Number(params.completedAtMs || 0) || null
498
+ });
420
499
  }
421
500
  }
422
501
 
423
- applyProviderItem(raw, inferredStatus = null) {
502
+ applyProviderItem(raw, inferredStatus = null, context = {}) {
424
503
  if (!raw || typeof raw !== 'object') return;
425
504
  const providerId = String(raw.id || '');
426
505
  const existing = providerId && this.messages.find(item => item.providerId === providerId);
@@ -429,10 +508,25 @@ class CodexStructuredSession extends EventEmitter {
429
508
  if (!kind) return;
430
509
  const text = kind === 'user' ? textFromInputItems(raw.content) : kind === 'assistant' ? String(raw.text || '')
431
510
  : kind === 'reasoning' ? (raw.text || (Array.isArray(raw.summary) ? raw.summary.join('\n') : Array.isArray(raw.content) ? raw.content.join('\n') : '')) : '';
432
- const inferredToolStatus = inferredStatus === 'completed' && ['failed', 'declined'].includes(raw.status)
433
- ? raw.status : inferredStatus;
434
- const patch = kind === 'tool' ? { ...toolDetails(raw), turnId: raw.turnId || this.currentTurnId,
435
- toolStatus: inferredToolStatus || raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
511
+ const inferredToolStatus = existing?.toolStatus === 'cancelled' ? 'cancelled'
512
+ : inferredStatus === 'completed' && ['failed', 'declined'].includes(raw.status) ? raw.status : inferredStatus;
513
+ const threadId = raw.threadId || context.threadId || null;
514
+ const trackedTurnId = threadId ? this.threadTurns.get(threadId)?.turnId : null;
515
+ const turnId = raw.turnId || context.turnId || trackedTurnId || this.currentTurnId;
516
+ const existingStartedAtMs = existing?.startedAtMs || existing?.createdAt || null;
517
+ const startedAtMs = context.startedAtMs || raw.startedAtMs || existingStartedAtMs;
518
+ const completedAtMs = context.completedAtMs || raw.completedAtMs || null;
519
+ const durationMs = Number(raw.durationMs || 0)
520
+ || (completedAtMs && startedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null)
521
+ || Number(existing?.durationMs || 0) || null;
522
+ const timing = {
523
+ ...(startedAtMs ? { startedAtMs } : {}),
524
+ ...(completedAtMs ? { completedAtMs } : {}),
525
+ ...(durationMs != null ? { durationMs } : {})
526
+ };
527
+ const patch = kind === 'tool' ? { ...toolDetails(raw), threadId, turnId,
528
+ ...timing, toolStatus: inferredToolStatus || raw.status || 'running' }
529
+ : { text, threadId, turnId, streaming: false };
436
530
  if (existing) {
437
531
  this.patch(existing.id, patch);
438
532
  } else if (kind === 'user') {
@@ -440,7 +534,7 @@ class CodexStructuredSession extends EventEmitter {
440
534
  if (local) this.patch(local.id, { providerId, ...patch });
441
535
  else this.append({ kind, providerId, ...patch });
442
536
  } else {
443
- this.append({ kind, providerId, ...patch });
537
+ this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
444
538
  }
445
539
  }
446
540
 
@@ -487,14 +581,28 @@ class CodexStructuredSession extends EventEmitter {
487
581
  archived: false,
488
582
  cwd: this.workingDir
489
583
  });
490
- return (result?.data || []).filter(item => !item.parentThreadId).map(item => ({
491
- id: item.id,
492
- sessionId: item.sessionId || item.id,
493
- preview: item.preview || item.name || '',
494
- updatedAt: Number(item.updatedAt || item.createdAt || 0) * 1000,
495
- cwd: item.cwd || '',
496
- current: item.id === this.threadId
497
- }));
584
+ const threads = (result?.data || []).filter(item => !item.parentThreadId);
585
+ const items = [];
586
+ for (const item of threads) {
587
+ let questions = [];
588
+ try {
589
+ const history = await this.request('thread/read', { threadId: item.id, includeTurns: true });
590
+ questions = recentUserQuestions(history?.thread);
591
+ } catch (error) {
592
+ this.logger.debugInfo?.(`[codex-app-server] unable to read resume preview for ${item.id}: ${error.message}`);
593
+ }
594
+ if (!questions[0]) questions[0] = item.preview || '';
595
+ if (questions.length < 2) questions.push('');
596
+ items.push({
597
+ id: item.id,
598
+ sessionId: item.sessionId || item.id,
599
+ questions: questions.slice(0, 2),
600
+ updatedAt: Number(item.updatedAt || item.createdAt || 0) * 1000,
601
+ cwd: item.cwd || '',
602
+ current: item.id === this.threadId
603
+ });
604
+ }
605
+ return items;
498
606
  }
499
607
 
500
608
  contextStatus() {
@@ -583,11 +691,17 @@ class CodexStructuredSession extends EventEmitter {
583
691
  return this.getControlState();
584
692
  }
585
693
 
586
- async sendUserMessage(text) {
694
+ async sendUserMessage(text, attachments = []) {
587
695
  const prompt = String(text || '').trim();
588
- if (!prompt || this.presentation !== 'structured' || this.status !== 'idle') return false;
696
+ const images = (Array.isArray(attachments) ? attachments : [])
697
+ .filter(item => item && typeof item.path === 'string' && item.path);
698
+ if ((!prompt && images.length === 0) || this.presentation !== 'structured' || this.status !== 'idle') return false;
589
699
  this.hasUnreadCompletion = false;
590
- this.append({ kind: 'user', text: prompt });
700
+ this.append({
701
+ kind: 'user',
702
+ text: prompt || '📷 Image attachment',
703
+ attachments: images.map(item => ({ id: item.id, name: item.name || 'image' }))
704
+ });
591
705
  await this.ensureProcess();
592
706
  if (!this.threadId) {
593
707
  const params = { cwd: this.workingDir };
@@ -603,7 +717,10 @@ class CodexStructuredSession extends EventEmitter {
603
717
  this.emitEvent({ type: 'state', state: this.getControlState() });
604
718
  }
605
719
  this.setStatus('running');
606
- const params = { threadId: this.threadId, input: [{ type: 'text', text: prompt }], cwd: this.workingDir, summary: 'auto' };
720
+ const input = [];
721
+ if (prompt) input.push({ type: 'text', text: prompt });
722
+ for (const image of images) input.push({ type: 'localImage', path: image.path });
723
+ const params = { threadId: this.threadId, input, cwd: this.workingDir, summary: 'auto' };
607
724
  if (this.hasModelOverride) params.model = this.model;
608
725
  if (this.hasEffortOverride) params.effort = this.effort;
609
726
  if (this.permissionMode) params.approvalPolicy = this.permissionMode;
@@ -663,9 +780,16 @@ class CodexStructuredSession extends EventEmitter {
663
780
  this.recordPermission(pending.public, 'denied', 'abort');
664
781
  }
665
782
  this.pendingPermissions.clear();
666
- if (this.threadId && this.currentTurnId) {
667
- this.request('turn/interrupt', { threadId: this.threadId, turnId: this.currentTurnId }).catch(error => {
668
- this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed: ${error.message}`);
783
+ const targets = Array.from(this.threadTurns.entries())
784
+ .filter(([, turn]) => turn?.turnId && turn.status === 'running')
785
+ .map(([threadId, turn]) => ({ threadId, turnId: turn.turnId }));
786
+ if (this.threadId && this.currentTurnId
787
+ && !targets.some(target => target.threadId === this.threadId && target.turnId === this.currentTurnId)) {
788
+ targets.push({ threadId: this.threadId, turnId: this.currentTurnId });
789
+ }
790
+ for (const target of targets) {
791
+ this.request('turn/interrupt', target).catch(error => {
792
+ this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed for ${target.threadId}/${target.turnId}: ${error.message}`);
669
793
  });
670
794
  }
671
795
  this.append({ kind: 'event', level: 'info', text: reason });
@@ -760,3 +884,4 @@ class CodexStructuredSession extends EventEmitter {
760
884
  }
761
885
 
762
886
  module.exports = CodexStructuredSession;
887
+ 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; }
@@ -99,6 +116,7 @@
99
116
  .codex-tool-title { flex: 0 1 auto; min-width: 0; color: #f5f5f7; font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
100
117
  .codex-tool-command { flex: 1; min-width: 0; color: #a9a9b0; font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
101
118
  .codex-tool-state { flex: 0 0 auto; color: #8e8e93; font-size: 11px; }
119
+ .codex-tool-duration { flex: 0 0 auto; color: #6f6f76; font-size: 10px; font-variant-numeric: tabular-nums; }
102
120
  .codex-tool-state.running::before { content: ''; display: inline-block; width: 9px; height: 9px; margin-right: 5px; border: 1.5px solid #8e8e93; border-top-color: transparent; border-radius: 50%; animation: codex-spin .8s linear infinite; vertical-align: -1px; }
103
121
  .codex-tool.error { border-color: rgba(255,59,48,.38); background: rgba(255,59,48,.07); }
104
122
  .codex-tool-body { border-top: 1px solid rgba(255,255,255,.08); padding: 9px 10px; }
@@ -240,6 +258,7 @@
240
258
  .claude-resume-item:last-child { border-bottom: 0; }
241
259
  .claude-resume-item:active { background: rgba(255,255,255,0.08); }
242
260
  .claude-resume-title { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; font-weight: 800; }
261
+ .codex-resume-question-secondary { min-height: 1.35em; color: var(--text-dim); font-size: 11px; margin-top: 3px; overflow-wrap: anywhere; }
243
262
  .claude-resume-meta { color: var(--text-dim); font-size: 11px; margin-top: 3px; overflow-wrap: anywhere; }
244
263
  #terminal .xterm, #terminal .xterm-viewport, #terminal .xterm-screen, #terminal .xterm-rows, #terminal .xterm-rows span { user-select: text !important; -webkit-user-select: text !important; -webkit-touch-callout: default; touch-action: pan-y; }
245
264
  #terminal .xterm-viewport { overflow-anchor: none; }
@@ -376,13 +395,17 @@
376
395
  <div id="timed-tag-rail"></div>
377
396
  <div id="input-row">
378
397
  <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>
398
+ <button id="timer-btn" title="Add image or schedule send" aria-label="Add image or schedule send">+</button>
382
399
  <button id="send-btn">
383
400
  <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
401
  </button>
385
402
  </div>
403
+ <div id="attachment-strip" aria-live="polite"></div>
404
+ <div id="composer-menu" role="menu">
405
+ <button id="attach-image-btn" class="composer-menu-btn" type="button" role="menuitem">Add image</button>
406
+ <button id="schedule-send-btn" class="composer-menu-btn" type="button" role="menuitem">Schedule send</button>
407
+ </div>
408
+ <input id="image-file-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
386
409
  <div id="claude-control-panel">
387
410
  <div class="claude-control-row">
388
411
  <div class="claude-picker-wrap">
@@ -637,6 +660,7 @@
637
660
  let codexModelPanelOpen = false;
638
661
  let codexModelCandidate = null;
639
662
  let codexResumePanelOpen = false;
663
+ let codexRenderFrame = null;
640
664
  const modifiers = { ctrl: false };
641
665
 
642
666
  function log(msg) {
@@ -942,6 +966,7 @@
942
966
  document.getElementById('codex-control-panel').style.display = codexChat ? 'block' : 'none';
943
967
  document.getElementById('codex-terminal-switch').style.display = isCodexSession() ? '' : 'none';
944
968
  document.getElementById('timer-btn').style.display = '';
969
+ document.getElementById('attach-image-btn').style.display = codexChat ? '' : 'none';
945
970
  document.getElementById('shortcut-rail').style.display = structured ? 'none' : '';
946
971
  document.getElementById('scroll-controls').style.display = structured ? 'none' : '';
947
972
  document.getElementById('cmd-input').placeholder = enabled ? 'Message Claude...' : codexChat ? 'Message Codex...' : 'Type a message...';
@@ -2048,6 +2073,16 @@
2048
2073
  if (status === 'completed') return item.exitCode && item.exitCode !== 0 ? 'failed' : 'completed';
2049
2074
  return status;
2050
2075
  }
2076
+ function formatCodexDuration(durationMs) {
2077
+ const value = Number(durationMs || 0);
2078
+ if (!(value > 0)) return '';
2079
+ if (value < 1000) return `${Math.round(value)}ms`;
2080
+ const seconds = value / 1000;
2081
+ if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`;
2082
+ if (seconds < 60) return `${Math.round(seconds)}s`;
2083
+ const minutes = Math.floor(seconds / 60);
2084
+ return `${minutes}m ${Math.round(seconds % 60)}s`;
2085
+ }
2051
2086
  function renderCodexDiff(diff) {
2052
2087
  return `<div class="codex-diff">${String(diff || '').split('\n').map(line => {
2053
2088
  const type = line.startsWith('+++') || line.startsWith('---') ? 'hunk' : line.startsWith('+') ? 'add' : line.startsWith('-') ? 'del' : line.startsWith('@@') ? 'hunk' : '';
@@ -2131,27 +2166,72 @@
2131
2166
  const command = item.name === 'CodexBash' ? item.command : item.title || item.tool || '';
2132
2167
  const icon = item.name === 'CodexBash' ? '>_' : item.name === 'McpTool' ? 'MCP' : item.name === 'Agent' ? 'A' : '•';
2133
2168
  const title = item.name === 'CodexBash' ? 'Command' : item.title || item.name || 'Tool';
2134
- const result = item.result || (item.name === 'McpTool' || item.name === 'Agent' ? codexJson(item.input) : '');
2135
- return `<details class="codex-tool${isError ? ' error' : ''}"><summary class="codex-tool-header"><span class="codex-tool-icon">${escapeHtml(icon)}</span><span class="codex-tool-title">${escapeHtml(title)}</span>${command && command !== title ? `<span class="codex-tool-command">${escapeHtml(command)}</span>` : '<span class="codex-tool-command"></span>'}<span class="codex-tool-state${runningClass}">${escapeHtml(status === 'completed' ? '' : status)}</span></summary>${result ? `<div class="codex-tool-body"><pre class="codex-tool-code">${escapeHtml(result)}</pre></div>` : ''}${permission ? renderCodexPermission(permission, true) : ''}</details>`;
2169
+ const hasInput = item.input && typeof item.input === 'object' && Object.keys(item.input).length > 0;
2170
+ const result = item.result || ((item.name === 'McpTool' || item.name === 'Agent') && hasInput ? codexJson(item.input) : '');
2171
+ const duration = status === 'running' ? '' : formatCodexDuration(item.durationMs);
2172
+ return `<details class="codex-tool${isError ? ' error' : ''}" data-codex-key="tool-${escapeHtml(item.id || item.providerId || '')}"><summary class="codex-tool-header"><span class="codex-tool-icon">${escapeHtml(icon)}</span><span class="codex-tool-title">${escapeHtml(title)}</span>${command && command !== title ? `<span class="codex-tool-command">${escapeHtml(command)}</span>` : '<span class="codex-tool-command"></span>'}${duration ? `<span class="codex-tool-duration">${escapeHtml(duration)}</span>` : ''}<span class="codex-tool-state${runningClass}">${escapeHtml(status === 'completed' ? '' : status)}</span></summary>${result ? `<div class="codex-tool-body"><pre class="codex-tool-code">${escapeHtml(result)}</pre></div>` : ''}${permission ? renderCodexPermission(permission, true) : ''}</details>`;
2136
2173
  }
2137
2174
  function renderCodexToolGroup(items, permissionById, usedPermissions, turnEnd = null) {
2138
2175
  const running = items.some(item => codexToolStatus(item) === 'running');
2139
2176
  const failed = items.some(item => codexToolStatus(item) === 'failed' || item.error);
2140
- const startedAt = Math.min(...items.map(item => Number(item.createdAt || Date.now())));
2177
+ const startedAt = Math.min(...items.map(item => Number(item.startedAtMs || item.createdAt || Date.now())));
2178
+ const itemCompletedAt = Math.max(...items.map(item => Number(item.completedAtMs || 0)));
2141
2179
  const storedDuration = Number(turnEnd?.durationMs || 0);
2142
2180
  const completedAt = Number(turnEnd?.createdAt || 0);
2143
- const durationMs = storedDuration > 0 ? storedDuration
2181
+ const durationMs = itemCompletedAt >= startedAt ? itemCompletedAt - startedAt : storedDuration > 0 ? storedDuration
2144
2182
  : (!running && completedAt >= startedAt ? completedAt - startedAt : 0);
2145
- const seconds = durationMs > 0 ? Math.max(1, Math.round(durationMs / 1000)) : null;
2183
+ const duration = formatCodexDuration(durationMs);
2146
2184
  const tools = items.map(item => {
2147
2185
  const permission = permissionById.get(String(item.providerId || ''));
2148
2186
  if (permission) usedPermissions.add(permission.id);
2149
2187
  return renderCodexTool(item, permission);
2150
2188
  }).join('');
2151
- const label = running ? 'Working' : failed ? 'Work finished with errors' : seconds ? `Worked for ${seconds}s` : 'Worked';
2152
- return `<details class="codex-work-group"${running ? ' open' : ''}><summary>${label} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
2189
+ const label = running ? 'Working' : failed ? 'Work finished with errors' : duration ? `Worked for ${duration}` : 'Worked';
2190
+ const key = items.map(item => item.id || item.providerId || '').join('-');
2191
+ return `<details class="codex-work-group" data-codex-key="group-${escapeHtml(key)}"${running ? ' open' : ''}><summary>${label} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
2192
+ }
2193
+ function syncCodexDom(current, next) {
2194
+ if (!current || !next) return;
2195
+ if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) {
2196
+ current.replaceWith(next.cloneNode(true));
2197
+ return;
2198
+ }
2199
+ if (current.nodeType === Node.TEXT_NODE) {
2200
+ if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
2201
+ return;
2202
+ }
2203
+ const currentKey = current.getAttribute?.('data-codex-key');
2204
+ const nextKey = next.getAttribute?.('data-codex-key');
2205
+ if (currentKey && nextKey && currentKey !== nextKey) {
2206
+ current.replaceWith(next.cloneNode(true));
2207
+ return;
2208
+ }
2209
+ const preserveOpen = current.tagName === 'DETAILS' && currentKey === nextKey;
2210
+ const wasOpen = preserveOpen ? current.open : false;
2211
+ for (const attribute of Array.from(current.attributes || [])) {
2212
+ if (!next.hasAttribute(attribute.name) && !(preserveOpen && attribute.name === 'open')) current.removeAttribute(attribute.name);
2213
+ }
2214
+ for (const attribute of Array.from(next.attributes || [])) {
2215
+ if (!(preserveOpen && attribute.name === 'open') && current.getAttribute(attribute.name) !== attribute.value) {
2216
+ current.setAttribute(attribute.name, attribute.value);
2217
+ }
2218
+ }
2219
+ if (preserveOpen) current.open = wasOpen;
2220
+ const currentChildren = Array.from(current.childNodes);
2221
+ const nextChildren = Array.from(next.childNodes);
2222
+ const shared = Math.min(currentChildren.length, nextChildren.length);
2223
+ for (let i = 0; i < shared; i++) syncCodexDom(currentChildren[i], nextChildren[i]);
2224
+ for (let i = current.childNodes.length - 1; i >= nextChildren.length; i--) current.childNodes[i].remove();
2225
+ for (let i = shared; i < nextChildren.length; i++) current.appendChild(nextChildren[i].cloneNode(true));
2153
2226
  }
2154
2227
  function renderCodexChat() {
2228
+ if (codexRenderFrame != null) return;
2229
+ codexRenderFrame = requestAnimationFrame(() => {
2230
+ codexRenderFrame = null;
2231
+ commitCodexChatRender();
2232
+ });
2233
+ }
2234
+ function commitCodexChatRender() {
2155
2235
  const container = document.getElementById('codex-chat-container');
2156
2236
  if (!container) return;
2157
2237
  const parts = [];
@@ -2175,16 +2255,20 @@
2175
2255
  }
2176
2256
  continue;
2177
2257
  }
2178
- if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant claude-md">${renderMarkdown(item.text || '')}</div>`);
2179
- else if (item.kind === 'user') parts.push(`<div class="codex-message user claude-md">${renderMarkdown(item.text || '')}</div>`);
2258
+ if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant claude-md" data-codex-key="message-${escapeHtml(item.id || '')}">${renderMarkdown(item.text || '')}</div>`);
2259
+ else if (item.kind === 'user') parts.push(`<div class="codex-message user claude-md" data-codex-key="message-${escapeHtml(item.id || '')}">${renderMarkdown(item.text || '')}</div>`);
2180
2260
  else if (item.kind === 'status') parts.push(renderCodexStatus(item));
2181
2261
  else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
2182
2262
  i += 1;
2183
2263
  }
2184
2264
  for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
2185
- const working = codexState.status === 'running'
2186
- ? '<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"></div>' : '';
2187
- container.innerHTML = `<div class="codex-conversation">${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
2265
+ const working = `<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"${codexState.status === 'running' ? '' : ' style="display:none"'}></div>`;
2266
+ const template = document.createElement('template');
2267
+ template.innerHTML = `<div class="codex-conversation">${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
2268
+ const next = template.content.firstElementChild;
2269
+ const current = container.firstElementChild;
2270
+ if (!current) container.appendChild(next);
2271
+ else syncCodexDom(current, next);
2188
2272
  requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
2189
2273
  }
2190
2274
 
@@ -2225,8 +2309,10 @@
2225
2309
  const el = document.getElementById('codex-state-bar');
2226
2310
  if (!el) return;
2227
2311
  const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
2312
+ const subagents = Number(codexState.activeSubagentCount || 0) || 0;
2228
2313
  const parts = [];
2229
2314
  if (pending || codexState.status === 'waiting_approval') parts.push(`<span class="claude-state-pill warn">${pending || 1} approval${pending === 1 ? '' : 's'}</span>`);
2315
+ if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
2230
2316
  el.innerHTML = parts.join('');
2231
2317
  el.style.display = parts.length ? 'flex' : 'none';
2232
2318
  }
@@ -2295,7 +2381,10 @@
2295
2381
  const data = await res.json();
2296
2382
  if (!res.ok || !data.success) throw new Error(data.error || 'Failed to load Codex sessions');
2297
2383
  const items = data.items || [];
2298
- panel.innerHTML = items.length ? items.map(item => `<button class="claude-resume-item" onclick="selectCodexResumeThread(decodePathValue('${encodePathValue(item.id)}'))"><div class="claude-resume-title"><span>${escapeHtml((item.preview || 'Codex session').slice(0, 48))}${item.current ? ' · current' : ''}</span><span>${escapeHtml(item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '')}</span></div><div class="claude-resume-meta">${escapeHtml(item.id.slice(0, 12))}</div></button>`).join('') : '<div class="claude-resume-meta" style="padding:12px;">No Codex sessions found for this folder.</div>';
2384
+ panel.innerHTML = items.length ? items.map(item => {
2385
+ const questions = Array.isArray(item.questions) ? item.questions : [];
2386
+ return `<button class="claude-resume-item" onclick="selectCodexResumeThread(decodePathValue('${encodePathValue(item.id)}'))"><div class="claude-resume-title"><span>${escapeHtml((questions[0] || 'Codex session').slice(0, 120))}${item.current ? ' · current' : ''}</span><span>${escapeHtml(item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '')}</span></div><div class="codex-resume-question-secondary">${escapeHtml((questions[1] || '').slice(0, 120))}</div></button>`;
2387
+ }).join('') : '<div class="claude-resume-meta" style="padding:12px;">No Codex sessions found for this folder.</div>';
2299
2388
  } catch (e) { panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(e.message)}</div>`; }
2300
2389
  }
2301
2390
  async function selectCodexResumeThread(threadId) {
@@ -2312,6 +2401,9 @@
2312
2401
  }
2313
2402
 
2314
2403
  function joinSession(id, sessionName, toolKey = null) {
2404
+ if (typeof clearImageAttachments === 'function' && selectedImageAttachments.length) {
2405
+ void clearImageAttachments();
2406
+ }
2315
2407
  stopTimedInputTimers();
2316
2408
  activeSessionId = id;
2317
2409
  window.activeSessionId = id;
@@ -2655,8 +2747,177 @@
2655
2747
  }
2656
2748
 
2657
2749
  const inputEl = document.getElementById('cmd-input');
2750
+ const imageFileInput = document.getElementById('image-file-input');
2751
+ const attachmentStrip = document.getElementById('attachment-strip');
2752
+ let selectedImageAttachments = [];
2658
2753
  let keepTerminalBottomForNextInput = false;
2659
2754
 
2755
+ function isCodexImageAttachmentAvailable() {
2756
+ return isCodexSession() && codexState.presentation === 'structured';
2757
+ }
2758
+
2759
+ function syncComposerButtonState() {
2760
+ const menuOpen = document.getElementById('composer-menu').classList.contains('active');
2761
+ const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
2762
+ document.getElementById('timer-btn').classList.toggle('active', menuOpen || timerOpen);
2763
+ }
2764
+
2765
+ function renderImageAttachments() {
2766
+ attachmentStrip.innerHTML = selectedImageAttachments.map(item => (
2767
+ `<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>`
2768
+ )).join('');
2769
+ attachmentStrip.classList.toggle('active', selectedImageAttachments.length > 0);
2770
+ updateTerminalControlsHeight();
2771
+ }
2772
+
2773
+ window.removeImageAttachment = async function(attachmentId) {
2774
+ const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
2775
+ selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
2776
+ renderImageAttachments();
2777
+ if (!attachment) return;
2778
+ clearInterval(attachment.indicatorTimer);
2779
+ attachment.abortUpload?.();
2780
+ if (attachment.uploading) return;
2781
+ try {
2782
+ await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
2783
+ } catch (_) {
2784
+ // The server also removes all attachments when the session ends.
2785
+ }
2786
+ };
2787
+
2788
+ async function clearImageAttachments() {
2789
+ const pending = selectedImageAttachments;
2790
+ selectedImageAttachments = [];
2791
+ renderImageAttachments();
2792
+ for (const item of pending) item.abortUpload?.();
2793
+ await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
2794
+ `/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
2795
+ { method: 'DELETE' }
2796
+ ).catch(() => null)));
2797
+ }
2798
+
2799
+ const IMAGE_UPLOAD_CHUNK_BYTES = 512 * 1024;
2800
+
2801
+ function uploadImageInChunks(sessionId, file, onProgress) {
2802
+ let xhr = null;
2803
+ let cancelled = false;
2804
+ const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
2805
+ const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
2806
+ return {
2807
+ abort: () => {
2808
+ cancelled = true;
2809
+ xhr?.abort();
2810
+ void fetch(`/api/sessions/${sessionId}/attachments/images/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
2811
+ },
2812
+ promise: (async () => {
2813
+ for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
2814
+ if (cancelled) throw new Error('Image upload cancelled');
2815
+ const start = chunkIndex * IMAGE_UPLOAD_CHUNK_BYTES;
2816
+ const chunk = file.slice(start, Math.min(file.size, start + IMAGE_UPLOAD_CHUNK_BYTES));
2817
+ const result = await new Promise((resolve, reject) => {
2818
+ xhr = new XMLHttpRequest();
2819
+ xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
2820
+ xhr.timeout = 60_000;
2821
+ xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
2822
+ xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
2823
+ xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
2824
+ xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
2825
+ xhr.onerror = () => reject(new Error('Network error while uploading image'));
2826
+ xhr.ontimeout = () => reject(new Error(`Image upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
2827
+ xhr.onabort = () => reject(new Error('Image upload cancelled'));
2828
+ xhr.onload = () => {
2829
+ let data = {};
2830
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
2831
+ if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
2832
+ reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
2833
+ return;
2834
+ }
2835
+ resolve(data);
2836
+ };
2837
+ xhr.send(chunk);
2838
+ });
2839
+ const confirmedBytes = Math.min(file.size, start + chunk.size);
2840
+ onProgress(Math.round((confirmedBytes / file.size) * 100), chunkIndex + 1, chunkTotal);
2841
+ if (result.complete) return result.attachment;
2842
+ }
2843
+ throw new Error('Image upload did not complete');
2844
+ })()
2845
+ };
2846
+ }
2847
+
2848
+ async function uploadImageFiles(files) {
2849
+ if (!isCodexImageAttachmentAvailable()) {
2850
+ alert('Image attachments are available only in Codex chat mode.');
2851
+ return;
2852
+ }
2853
+ const remaining = 5 - selectedImageAttachments.length;
2854
+ const batch = Array.from(files).slice(0, remaining);
2855
+ if (files.length > remaining) alert('You can attach up to 5 images at a time.');
2856
+ for (const file of batch) {
2857
+ if (file.size > 50 * 1024 * 1024) {
2858
+ alert(`${file.name} is larger than 50 MB.`);
2859
+ continue;
2860
+ }
2861
+ const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
2862
+ const pending = {
2863
+ id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
2864
+ name: file.name || 'image',
2865
+ sessionId: activeSessionId,
2866
+ uploading: true,
2867
+ progress: 0,
2868
+ progressKnown: true,
2869
+ status: '0%',
2870
+ abortUpload: null
2871
+ };
2872
+ selectedImageAttachments.push(pending);
2873
+ renderImageAttachments();
2874
+ try {
2875
+ const upload = uploadImageInChunks(activeSessionId, file, progress => {
2876
+ pending.progress = progress;
2877
+ pending.status = `${progress}%`;
2878
+ renderImageAttachments();
2879
+ });
2880
+ pending.abortUpload = upload.abort;
2881
+ const attachment = await upload.promise;
2882
+ const index = selectedImageAttachments.indexOf(pending);
2883
+ if (index < 0) {
2884
+ await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
2885
+ continue;
2886
+ }
2887
+ selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
2888
+ renderImageAttachments();
2889
+ } catch (e) {
2890
+ selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
2891
+ renderImageAttachments();
2892
+ if (e.message === 'Image upload cancelled') continue;
2893
+ alert(`Could not add ${file.name}: ${e.message}`);
2894
+ }
2895
+ }
2896
+ }
2897
+
2898
+ function closeComposerMenu() {
2899
+ document.getElementById('composer-menu').classList.remove('active');
2900
+ syncComposerButtonState();
2901
+ updateTerminalControlsHeight();
2902
+ }
2903
+
2904
+ function openTimedSendPanel() {
2905
+ closeComposerMenu();
2906
+ if (isClaudeSession()) {
2907
+ closeClaudePicker();
2908
+ closeClaudeUsagePanel();
2909
+ claudeResumePanelOpen = false;
2910
+ document.getElementById('claude-resume-panel').classList.remove('active');
2911
+ }
2912
+ initTimedDelaySelectors();
2913
+ resetTimedEditor({ keepInput: true });
2914
+ document.getElementById('timed-send-panel').classList.add('active');
2915
+ updateTimedSendPreview();
2916
+ loadTimedInputs();
2917
+ syncComposerButtonState();
2918
+ updateTerminalControlsHeight();
2919
+ }
2920
+
2660
2921
  function markInputEditStart() {
2661
2922
  keepTerminalBottomForNextInput = keepTerminalBottomForNextInput || isTerminalAtBottom();
2662
2923
  }
@@ -2672,6 +2933,11 @@
2672
2933
 
2673
2934
  function performSend() {
2674
2935
  const val = inputEl.value;
2936
+ const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
2937
+ if (selectedImageAttachments.some(item => item.uploading)) {
2938
+ alert('Wait for image uploads to finish before sending.');
2939
+ return;
2940
+ }
2675
2941
  if (val && isClaudeSession()) {
2676
2942
  if (currentSocket && currentSocket.readyState === 1) {
2677
2943
  currentSocket.send(JSON.stringify({ type: 'claude-input', text: val }));
@@ -2680,10 +2946,18 @@
2680
2946
  inputEl.style.height = '38px';
2681
2947
  return;
2682
2948
  }
2683
- if (val && isCodexSession() && codexState.presentation === 'structured') {
2684
- if (currentSocket && currentSocket.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-input', text: val }));
2949
+ if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
2950
+ if (currentSocket && currentSocket.readyState === 1) {
2951
+ currentSocket.send(JSON.stringify({
2952
+ type: 'codex-input',
2953
+ text: val,
2954
+ attachmentIds: readyImageAttachments.map(item => item.id)
2955
+ }));
2956
+ }
2685
2957
  inputEl.value = '';
2686
2958
  inputEl.style.height = '38px';
2959
+ selectedImageAttachments = [];
2960
+ renderImageAttachments();
2687
2961
  return;
2688
2962
  }
2689
2963
  if (val) {
@@ -2762,9 +3036,9 @@
2762
3036
 
2763
3037
  function closeTimedSendPanel() {
2764
3038
  document.getElementById('timed-send-panel').classList.remove('active');
2765
- document.getElementById('timer-btn').classList.remove('active');
2766
3039
  editingTimedInputId = null;
2767
3040
  renderTimedTags();
3041
+ syncComposerButtonState();
2768
3042
  updateTerminalControlsHeight();
2769
3043
  }
2770
3044
 
@@ -2813,6 +3087,7 @@
2813
3087
  function editTimedInput(id) {
2814
3088
  const item = timedInputs.find(value => value.id === id);
2815
3089
  if (!item) return;
3090
+ closeComposerMenu();
2816
3091
  initTimedDelaySelectors();
2817
3092
  editingTimedInputId = id;
2818
3093
  inputEl.value = item.text || '';
@@ -2823,9 +3098,9 @@
2823
3098
  document.getElementById('timed-cancel-edit-btn').style.display = '';
2824
3099
  document.getElementById('timed-delete-btn').style.display = '';
2825
3100
  document.getElementById('timed-send-panel').classList.add('active');
2826
- document.getElementById('timer-btn').classList.add('active');
2827
3101
  updateTimedSendPreview();
2828
3102
  renderTimedTags();
3103
+ syncComposerButtonState();
2829
3104
  updateTerminalControlsHeight();
2830
3105
  }
2831
3106
 
@@ -2871,24 +3146,23 @@
2871
3146
 
2872
3147
  document.getElementById('send-btn').addEventListener('click', performSend);
2873
3148
  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();
3149
+ const menu = document.getElementById('composer-menu');
3150
+ const willOpen = !menu.classList.contains('active');
3151
+ document.getElementById('timed-send-panel').classList.remove('active');
3152
+ menu.classList.toggle('active', willOpen);
3153
+ syncComposerButtonState();
2890
3154
  updateTerminalControlsHeight();
2891
3155
  });
3156
+ document.getElementById('attach-image-btn').addEventListener('click', () => {
3157
+ closeComposerMenu();
3158
+ imageFileInput.click();
3159
+ });
3160
+ document.getElementById('schedule-send-btn').addEventListener('click', openTimedSendPanel);
3161
+ imageFileInput.addEventListener('change', () => {
3162
+ const files = imageFileInput.files;
3163
+ if (files?.length) void uploadImageFiles(files);
3164
+ imageFileInput.value = '';
3165
+ });
2892
3166
  inputEl.addEventListener('keydown', (e) => {
2893
3167
  if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); performSend(); }
2894
3168
  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.26",
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": {