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.
- package/lib/codex/structured-session.js +175 -50
- package/lib/commands/web.js +47 -1
- package/lib/session/session-manager.js +200 -0
- package/lib/web/index.html +311 -37
- package/package.json +1 -1
|
@@ -43,6 +43,19 @@ function safeJson(value) {
|
|
|
43
43
|
try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
|
|
47
|
+
const options = { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] };
|
|
48
|
+
|
|
49
|
+
// Globally installed npm CLIs expose a .cmd shim on Windows. child_process.spawn
|
|
50
|
+
// does not resolve that shim without a shell, causing `spawn codex ENOENT`.
|
|
51
|
+
if (platform === 'win32') {
|
|
52
|
+
options.shell = true;
|
|
53
|
+
options.windowsHide = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return options;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
function textFromInputItems(content) {
|
|
47
60
|
return (Array.isArray(content) ? content : [])
|
|
48
61
|
.filter(item => item && item.type === 'text')
|
|
@@ -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
|
-
|
|
92
|
-
|
|
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 ||
|
|
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,
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
this.
|
|
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.
|
|
358
|
-
this.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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.
|
|
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 (
|
|
375
|
-
|
|
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 =
|
|
433
|
-
? raw.status : inferredStatus;
|
|
434
|
-
const
|
|
435
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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;
|
package/lib/commands/web.js
CHANGED
|
@@ -266,6 +266,51 @@ async function webCommand(options) {
|
|
|
266
266
|
res.json({ success: true });
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
// Browser images are stored only in a private, per-session temporary directory.
|
|
270
|
+
// Codex receives the resulting local path through its app-server protocol.
|
|
271
|
+
app.post('/api/sessions/:id/attachments/images', express.raw({ type: () => true, limit: '50mb' }), async (req, res) => {
|
|
272
|
+
try {
|
|
273
|
+
const attachment = await sessionManager.storeCodexImageAttachment(req.params.id, req.body);
|
|
274
|
+
res.status(201).json({ success: true, attachment });
|
|
275
|
+
} catch (e) {
|
|
276
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Mobile Safari can coalesce progress events for a single large request.
|
|
281
|
+
// Small sequential chunks let the browser report progress from server receipts.
|
|
282
|
+
app.post('/api/sessions/:id/attachments/images/chunks', express.raw({ type: () => true, limit: '1mb' }), async (req, res) => {
|
|
283
|
+
try {
|
|
284
|
+
const result = await sessionManager.appendCodexImageChunk(req.params.id, {
|
|
285
|
+
uploadId: req.get('X-Glad-Upload-Id'),
|
|
286
|
+
chunkIndex: req.get('X-Glad-Chunk-Index'),
|
|
287
|
+
chunkTotal: req.get('X-Glad-Chunk-Total')
|
|
288
|
+
}, req.body);
|
|
289
|
+
res.json({ success: true, ...result });
|
|
290
|
+
} catch (e) {
|
|
291
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
app.delete('/api/sessions/:id/attachments/images/uploads/:uploadId', async (req, res) => {
|
|
296
|
+
try {
|
|
297
|
+
const removed = await sessionManager.discardCodexImageUpload(req.params.id, req.params.uploadId);
|
|
298
|
+
res.json({ success: true, removed });
|
|
299
|
+
} catch (e) {
|
|
300
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
app.delete('/api/sessions/:id/attachments/images/:attachmentId', async (req, res) => {
|
|
305
|
+
try {
|
|
306
|
+
const removed = await sessionManager.discardCodexImageAttachment(req.params.id, req.params.attachmentId);
|
|
307
|
+
if (!removed) return res.status(404).json({ error: 'Image attachment not found' });
|
|
308
|
+
res.json({ success: true });
|
|
309
|
+
} catch (e) {
|
|
310
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
269
314
|
// API: Delete/Kill session
|
|
270
315
|
app.delete('/api/sessions/:id', (req, res) => {
|
|
271
316
|
sessionManager.kill(req.params.id);
|
|
@@ -514,7 +559,8 @@ async function webCommand(options) {
|
|
|
514
559
|
sessionManager.abortClaude(sessionId);
|
|
515
560
|
}
|
|
516
561
|
if (payload.type === 'codex-input') {
|
|
517
|
-
sessionManager.
|
|
562
|
+
sessionManager.sendCodexInput(sessionId, payload.text || '', payload.attachmentIds || [])
|
|
563
|
+
.catch(error => logger.error(`Codex input error: ${error.message}`));
|
|
518
564
|
}
|
|
519
565
|
if (payload.type === 'codex-permission') {
|
|
520
566
|
const codex = sessionManager.get(sessionId);
|
|
@@ -21,6 +21,30 @@ function previewText(text, maxChars = 320) {
|
|
|
21
21
|
return normalized.length > maxChars ? normalized.slice(-maxChars) : normalized;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const CODEX_IMAGE_MAX_BYTES = 50 * 1024 * 1024;
|
|
25
|
+
const CODEX_IMAGE_MAX_PER_SESSION = 5;
|
|
26
|
+
const CODEX_IMAGE_CLEANUP_DELAY_MS = 5 * 60 * 1000;
|
|
27
|
+
const CODEX_IMAGE_MAX_CHUNKS = 128;
|
|
28
|
+
|
|
29
|
+
function detectImageExtension(buffer) {
|
|
30
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null;
|
|
31
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'png';
|
|
32
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg';
|
|
33
|
+
if (buffer.subarray(0, 6).equals(Buffer.from('GIF87a')) || buffer.subarray(0, 6).equals(Buffer.from('GIF89a'))) return 'gif';
|
|
34
|
+
if (buffer.subarray(0, 4).equals(Buffer.from('RIFF')) && buffer.subarray(8, 12).equals(Buffer.from('WEBP'))) return 'webp';
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function attachmentError(message, statusCode = 400) {
|
|
39
|
+
const error = new Error(message);
|
|
40
|
+
error.statusCode = statusCode;
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isSafeUploadId(value) {
|
|
45
|
+
return typeof value === 'string' && /^[a-zA-Z0-9-]{8,100}$/.test(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
class SessionManager extends EventEmitter {
|
|
25
49
|
constructor({ baseDir, renderHistoryTools, debugHistoryEnabled = false, logger, hasConnectedSessionClient } = {}) {
|
|
26
50
|
super();
|
|
@@ -30,6 +54,8 @@ class SessionManager extends EventEmitter {
|
|
|
30
54
|
this.logger = logger || console;
|
|
31
55
|
this.hasConnectedSessionClient = hasConnectedSessionClient || (() => false);
|
|
32
56
|
this.sessions = new Map();
|
|
57
|
+
this.codexImageRoot = path.join(os.tmpdir(), 'glad', 'codex-images');
|
|
58
|
+
this.codexImageUploadRoot = path.join(os.tmpdir(), 'glad', 'codex-image-uploads');
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
list() {
|
|
@@ -177,6 +203,8 @@ class SessionManager extends EventEmitter {
|
|
|
177
203
|
}
|
|
178
204
|
const id = uuidv4();
|
|
179
205
|
const session = new CodexStructuredSession({ id, tool, workingDir: sessionDir, name: name || tool.displayName, logger: this.logger, options: codexOptions });
|
|
206
|
+
session.imageAttachments = new Map();
|
|
207
|
+
session.imageUploads = new Map();
|
|
180
208
|
this.sessions.set(id, session);
|
|
181
209
|
session.on('event', event => this.emit('codex-event', { sessionId: id, event, session }));
|
|
182
210
|
session.on('output', data => this.emit('output', { sessionId: id, data, session }));
|
|
@@ -203,6 +231,155 @@ class SessionManager extends EventEmitter {
|
|
|
203
231
|
return session.sendUserMessage(text);
|
|
204
232
|
}
|
|
205
233
|
|
|
234
|
+
async storeCodexImageAttachment(id, bytes) {
|
|
235
|
+
const session = this.get(id);
|
|
236
|
+
if (!session) throw attachmentError('Session not found', 404);
|
|
237
|
+
if (session.kind !== 'codex-structured' || session.presentation !== 'structured') {
|
|
238
|
+
throw attachmentError('Image attachments are available only in Codex chat mode');
|
|
239
|
+
}
|
|
240
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw attachmentError('Image data is required');
|
|
241
|
+
if (bytes.length > CODEX_IMAGE_MAX_BYTES) throw attachmentError('Image must be 50 MB or smaller');
|
|
242
|
+
if (session.imageAttachments.size >= CODEX_IMAGE_MAX_PER_SESSION) {
|
|
243
|
+
throw attachmentError(`You can attach at most ${CODEX_IMAGE_MAX_PER_SESSION} images at a time`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const extension = detectImageExtension(bytes);
|
|
247
|
+
if (!extension) throw attachmentError('Only PNG, JPEG, GIF, and WebP images are supported');
|
|
248
|
+
|
|
249
|
+
const directory = path.join(this.codexImageRoot, session.id);
|
|
250
|
+
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
251
|
+
const attachment = {
|
|
252
|
+
id: uuidv4(),
|
|
253
|
+
name: `image.${extension}`,
|
|
254
|
+
path: path.join(directory, `${uuidv4()}.${extension}`),
|
|
255
|
+
size: bytes.length,
|
|
256
|
+
createdAt: Date.now(),
|
|
257
|
+
cleanupTimer: null
|
|
258
|
+
};
|
|
259
|
+
await fs.promises.writeFile(attachment.path, bytes, { mode: 0o600, flag: 'wx' });
|
|
260
|
+
session.imageAttachments.set(attachment.id, attachment);
|
|
261
|
+
return { id: attachment.id, name: attachment.name, size: attachment.size };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async appendCodexImageChunk(id, input = {}, bytes) {
|
|
265
|
+
const session = this.get(id);
|
|
266
|
+
if (!session) throw attachmentError('Session not found', 404);
|
|
267
|
+
if (session.kind !== 'codex-structured' || session.presentation !== 'structured') {
|
|
268
|
+
throw attachmentError('Image attachments are available only in Codex chat mode');
|
|
269
|
+
}
|
|
270
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) throw attachmentError('Image chunk is required');
|
|
271
|
+
const uploadId = String(input.uploadId || '');
|
|
272
|
+
const chunkIndex = Number(input.chunkIndex);
|
|
273
|
+
const chunkTotal = Number(input.chunkTotal);
|
|
274
|
+
if (!isSafeUploadId(uploadId)) throw attachmentError('Invalid image upload id');
|
|
275
|
+
if (!Number.isInteger(chunkIndex) || !Number.isInteger(chunkTotal) || chunkIndex < 0 || chunkTotal < 1 || chunkTotal > CODEX_IMAGE_MAX_CHUNKS || chunkIndex >= chunkTotal) {
|
|
276
|
+
throw attachmentError('Invalid image chunk metadata');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let upload = session.imageUploads.get(uploadId);
|
|
280
|
+
if (!upload) {
|
|
281
|
+
if (chunkIndex !== 0) throw attachmentError('Image upload must start with the first chunk');
|
|
282
|
+
const directory = path.join(this.codexImageUploadRoot, session.id);
|
|
283
|
+
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
284
|
+
upload = {
|
|
285
|
+
id: uploadId,
|
|
286
|
+
path: path.join(directory, `${uploadId}.part`),
|
|
287
|
+
chunkTotal,
|
|
288
|
+
nextChunkIndex: 0,
|
|
289
|
+
bytes: 0
|
|
290
|
+
};
|
|
291
|
+
session.imageUploads.set(uploadId, upload);
|
|
292
|
+
}
|
|
293
|
+
if (upload.chunkTotal !== chunkTotal || upload.nextChunkIndex !== chunkIndex) {
|
|
294
|
+
throw attachmentError('Image chunks arrived out of order');
|
|
295
|
+
}
|
|
296
|
+
if (upload.bytes + bytes.length > CODEX_IMAGE_MAX_BYTES) {
|
|
297
|
+
await this.discardCodexImageUpload(id, uploadId);
|
|
298
|
+
throw attachmentError('Image must be 50 MB or smaller');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (chunkIndex === 0) await fs.promises.writeFile(upload.path, bytes, { mode: 0o600, flag: 'wx' });
|
|
302
|
+
else await fs.promises.appendFile(upload.path, bytes, { mode: 0o600 });
|
|
303
|
+
upload.bytes += bytes.length;
|
|
304
|
+
upload.nextChunkIndex += 1;
|
|
305
|
+
|
|
306
|
+
if (upload.nextChunkIndex < upload.chunkTotal) {
|
|
307
|
+
return { complete: false, receivedChunks: upload.nextChunkIndex, size: upload.bytes };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
session.imageUploads.delete(uploadId);
|
|
311
|
+
try {
|
|
312
|
+
const image = await fs.promises.readFile(upload.path);
|
|
313
|
+
const attachment = await this.storeCodexImageAttachment(id, image);
|
|
314
|
+
return { complete: true, attachment };
|
|
315
|
+
} finally {
|
|
316
|
+
await fs.promises.rm(upload.path, { force: true });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async discardCodexImageUpload(id, uploadId) {
|
|
321
|
+
const session = this.get(id);
|
|
322
|
+
if (!session || !session.imageUploads || !isSafeUploadId(uploadId)) return false;
|
|
323
|
+
const upload = session.imageUploads.get(uploadId);
|
|
324
|
+
if (!upload) return false;
|
|
325
|
+
session.imageUploads.delete(uploadId);
|
|
326
|
+
await fs.promises.rm(upload.path, { force: true });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async discardCodexImageAttachment(id, attachmentId) {
|
|
331
|
+
const session = this.get(id);
|
|
332
|
+
if (!session || session.kind !== 'codex-structured') return false;
|
|
333
|
+
const attachment = session.imageAttachments.get(attachmentId);
|
|
334
|
+
if (!attachment) return false;
|
|
335
|
+
clearTimeout(attachment.cleanupTimer);
|
|
336
|
+
session.imageAttachments.delete(attachmentId);
|
|
337
|
+
await fs.promises.rm(attachment.path, { force: true });
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
getCodexImageAttachments(id, attachmentIds = []) {
|
|
342
|
+
const session = this.get(id);
|
|
343
|
+
if (!session || session.kind !== 'codex-structured') throw attachmentError('Codex session not found', 404);
|
|
344
|
+
const ids = Array.isArray(attachmentIds) ? attachmentIds : [];
|
|
345
|
+
if (ids.length > CODEX_IMAGE_MAX_PER_SESSION) throw attachmentError(`You can attach at most ${CODEX_IMAGE_MAX_PER_SESSION} images at a time`);
|
|
346
|
+
const uniqueIds = [...new Set(ids.map(value => String(value)))];
|
|
347
|
+
if (uniqueIds.length !== ids.length) throw attachmentError('Duplicate image attachment');
|
|
348
|
+
return uniqueIds.map(attachmentId => {
|
|
349
|
+
const attachment = session.imageAttachments.get(attachmentId);
|
|
350
|
+
if (!attachment) throw attachmentError('Image attachment is no longer available');
|
|
351
|
+
return attachment;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
scheduleCodexImageCleanup(id, attachmentIds) {
|
|
356
|
+
const session = this.get(id);
|
|
357
|
+
if (!session || session.kind !== 'codex-structured') return;
|
|
358
|
+
for (const attachmentId of attachmentIds) {
|
|
359
|
+
const attachment = session.imageAttachments.get(attachmentId);
|
|
360
|
+
if (!attachment) continue;
|
|
361
|
+
clearTimeout(attachment.cleanupTimer);
|
|
362
|
+
attachment.cleanupTimer = setTimeout(() => {
|
|
363
|
+
this.discardCodexImageAttachment(id, attachmentId).catch(error => {
|
|
364
|
+
this.logger.debugInfo?.(`[codex-image] cleanup failed: ${error.message}`);
|
|
365
|
+
});
|
|
366
|
+
}, CODEX_IMAGE_CLEANUP_DELAY_MS);
|
|
367
|
+
attachment.cleanupTimer.unref?.();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async sendCodexInput(id, text, attachmentIds = []) {
|
|
372
|
+
const session = this.get(id);
|
|
373
|
+
if (!session || session.kind !== 'codex-structured') return false;
|
|
374
|
+
const attachments = this.getCodexImageAttachments(id, attachmentIds);
|
|
375
|
+
const prompt = String(text || '');
|
|
376
|
+
if (!prompt.trim() && attachments.length === 0) return false;
|
|
377
|
+
this.markSessionInput(session, prompt || '[image attachment]');
|
|
378
|
+
const sent = await session.sendUserMessage(prompt, attachments);
|
|
379
|
+
if (sent && attachments.length) this.scheduleCodexImageCleanup(id, attachments.map(item => item.id));
|
|
380
|
+
return sent;
|
|
381
|
+
}
|
|
382
|
+
|
|
206
383
|
respondClaudePermission(id, permissionId, approved, action = null) {
|
|
207
384
|
const session = this.get(id);
|
|
208
385
|
if (!session || session.kind !== 'claude-structured') return false;
|
|
@@ -438,6 +615,8 @@ class SessionManager extends EventEmitter {
|
|
|
438
615
|
if (!session) return false;
|
|
439
616
|
clearTimeout(session.completionTimer);
|
|
440
617
|
this.clearTimedInputs(session);
|
|
618
|
+
this.clearCodexImageUploads(session);
|
|
619
|
+
this.clearCodexImageAttachments(session);
|
|
441
620
|
this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
|
|
442
621
|
this.disposeSessionHistory(session);
|
|
443
622
|
if (['claude-structured', 'codex-structured'].includes(session.kind)) {
|
|
@@ -454,6 +633,8 @@ class SessionManager extends EventEmitter {
|
|
|
454
633
|
for (const session of this.sessions.values()) {
|
|
455
634
|
clearTimeout(session.completionTimer);
|
|
456
635
|
this.clearTimedInputs(session);
|
|
636
|
+
this.clearCodexImageUploads(session);
|
|
637
|
+
this.clearCodexImageAttachments(session);
|
|
457
638
|
this.disposeSessionHistory(session);
|
|
458
639
|
session.ptyManager.kill();
|
|
459
640
|
}
|
|
@@ -802,6 +983,8 @@ class SessionManager extends EventEmitter {
|
|
|
802
983
|
this.logger.info(`Session ${session.id} (${session.name}) exited.`);
|
|
803
984
|
clearTimeout(session.completionTimer);
|
|
804
985
|
this.clearTimedInputs(session);
|
|
986
|
+
this.clearCodexImageUploads(session);
|
|
987
|
+
this.clearCodexImageAttachments(session);
|
|
805
988
|
this.disposeSessionHistory(session);
|
|
806
989
|
this.sessions.delete(session.id);
|
|
807
990
|
this.emit('exit', { sessionId: session.id, session });
|
|
@@ -844,6 +1027,23 @@ class SessionManager extends EventEmitter {
|
|
|
844
1027
|
session.timedInputs.clear();
|
|
845
1028
|
}
|
|
846
1029
|
|
|
1030
|
+
clearCodexImageAttachments(session) {
|
|
1031
|
+
if (!session || !session.imageAttachments) return;
|
|
1032
|
+
for (const attachment of session.imageAttachments.values()) clearTimeout(attachment.cleanupTimer);
|
|
1033
|
+
session.imageAttachments.clear();
|
|
1034
|
+
fs.promises.rm(path.join(this.codexImageRoot, session.id), { recursive: true, force: true }).catch(error => {
|
|
1035
|
+
this.logger.debugInfo?.(`[codex-image] session cleanup failed: ${error.message}`);
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
clearCodexImageUploads(session) {
|
|
1040
|
+
if (!session || !session.imageUploads) return;
|
|
1041
|
+
session.imageUploads.clear();
|
|
1042
|
+
fs.promises.rm(path.join(this.codexImageUploadRoot, session.id), { recursive: true, force: true }).catch(error => {
|
|
1043
|
+
this.logger.debugInfo?.(`[codex-image] upload cleanup failed: ${error.message}`);
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
847
1047
|
getSessionDiagnostics(session, extra = {}) {
|
|
848
1048
|
if (['claude-structured', 'codex-structured'].includes(session.kind)) {
|
|
849
1049
|
return {
|
package/lib/web/index.html
CHANGED
|
@@ -52,9 +52,26 @@
|
|
|
52
52
|
#cmd-input { flex: 1; min-height: 38px; max-height: 150px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #fff; padding: 9px 16px; font-size: 16px; outline: none; resize: none; overflow-y: auto; line-height: 20px; box-sizing: border-box; transition: background 0.18s ease, border-color 0.18s ease; }
|
|
53
53
|
#cmd-input::placeholder { color: rgba(255,255,255,0.45); }
|
|
54
54
|
#cmd-input:focus { background: rgba(255,255,255,0.1); border-color: rgba(0,122,255,0.42); color: #fff; }
|
|
55
|
-
#timer-btn { width: 38px; height: 38px; margin-left: 8px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #d1d5db; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
|
|
55
|
+
#timer-btn { width: 38px; height: 38px; margin-left: 8px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); border-radius: 19px; color: #d1d5db; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; font-size: 24px; line-height: 1; }
|
|
56
56
|
#timer-btn.active { color: #fff; border-color: rgba(0,122,255,0.45); background: rgba(0,122,255,0.22); }
|
|
57
57
|
#send-btn { width: 44px; height: 38px; margin-left: 10px; background: #007aff; border: none; border-radius: 19px; color: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
|
|
58
|
+
#composer-menu { display: none; width: min(calc(100% - 28px), var(--control-content-max)); margin: -2px auto 8px auto; padding: 6px; border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; background: rgba(38,38,42,0.98); box-shadow: 0 12px 28px rgba(0,0,0,0.28); box-sizing: border-box; }
|
|
59
|
+
#composer-menu.active { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
|
60
|
+
.composer-menu-btn { min-height: 42px; border: 0; border-radius: 9px; background: rgba(255,255,255,0.08); color: #fff; font-size: 14px; font-weight: 650; cursor: pointer; }
|
|
61
|
+
.composer-menu-btn:hover { background: rgba(255,255,255,0.14); }
|
|
62
|
+
#attachment-strip { display: none; width: min(calc(100% - 28px), var(--control-content-max)); margin: -3px auto 2px auto; gap: 7px; overflow-x: auto; padding: 0 0 4px 0; box-sizing: border-box; scrollbar-width: none; }
|
|
63
|
+
#attachment-strip.active { display: flex; }
|
|
64
|
+
#attachment-strip::-webkit-scrollbar { display: none; }
|
|
65
|
+
.attachment-chip { min-width: 0; max-width: 210px; display: flex; align-items: center; gap: 7px; padding: 7px 9px; border: 1px solid rgba(0,122,255,0.45); border-radius: 10px; background: rgba(0,122,255,0.14); color: #e9f2ff; font-size: 12px; }
|
|
66
|
+
.attachment-chip.uploading { border-color: rgba(255,159,10,0.55); background: rgba(255,159,10,0.12); }
|
|
67
|
+
.attachment-chip-content { min-width: 0; flex: 1; }
|
|
68
|
+
.attachment-chip-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
69
|
+
.attachment-progress { display: block; height: 5px; margin-top: 5px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,0.16); }
|
|
70
|
+
.attachment-progress > span { display: block; height: 100%; border-radius: inherit; background: #ff9f0a; transition: width .15s ease; }
|
|
71
|
+
.attachment-progress.estimated > span { background: repeating-linear-gradient(90deg, #ff9f0a 0 12px, #ffd37b 12px 24px); background-size: 48px 100%; animation: attachment-upload-pulse .75s linear infinite; }
|
|
72
|
+
.attachment-status { display: block; margin-top: 3px; color: #ffd59a; font-size: 10px; }
|
|
73
|
+
@keyframes attachment-upload-pulse { from { background-position: 0 0; } to { background-position: 48px 0; } }
|
|
74
|
+
.attachment-remove { width: 19px; height: 19px; padding: 0; border: 0; border-radius: 50%; background: rgba(255,255,255,0.16); color: #fff; font-size: 15px; line-height: 18px; cursor: pointer; flex: 0 0 auto; }
|
|
58
75
|
#timed-send-panel { display: none; margin: 0 14px 10px 14px; padding: 12px; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; background: rgba(28,28,30,0.98); box-sizing: border-box; }
|
|
59
76
|
#timed-send-panel.active { display: block; }
|
|
60
77
|
.timed-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: center; }
|
|
@@ -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="
|
|
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
|
|
2135
|
-
|
|
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
|
|
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' :
|
|
2152
|
-
|
|
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
|
-
|
|
2187
|
-
|
|
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 =>
|
|
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)
|
|
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
|
|
2875
|
-
const
|
|
2876
|
-
document.getElementById('
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
closeClaudePicker();
|
|
2880
|
-
closeClaudeUsagePanel();
|
|
2881
|
-
claudeResumePanelOpen = false;
|
|
2882
|
-
document.getElementById('claude-resume-panel').classList.remove('active');
|
|
2883
|
-
}
|
|
2884
|
-
initTimedDelaySelectors();
|
|
2885
|
-
resetTimedEditor({ keepInput: true });
|
|
2886
|
-
updateTimedSendPreview();
|
|
2887
|
-
loadTimedInputs();
|
|
2888
|
-
}
|
|
2889
|
-
else closeTimedSendPanel();
|
|
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') {
|