glad-web 1.0.23 → 1.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/codex/structured-session.js +141 -23
- package/lib/commands/web.js +50 -1
- package/lib/session/rendered-history.js +1 -1
- package/lib/session/session-manager.js +206 -0
- package/lib/web/index.html +296 -37
- package/package.json +3 -3
|
@@ -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')
|
|
@@ -122,6 +135,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
122
135
|
this.threadId = options.resume || null;
|
|
123
136
|
this.currentTurnId = null;
|
|
124
137
|
this.currentTurnStartedAt = null;
|
|
138
|
+
this.tokenUsage = null;
|
|
125
139
|
this.permissionMode = normalizePermissionMode(options.permissionMode);
|
|
126
140
|
this.sandboxMode = normalizeSandboxMode(options.sandboxMode);
|
|
127
141
|
this.effectivePermissionMode = null;
|
|
@@ -129,6 +143,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
129
143
|
this.configPermissionMode = null;
|
|
130
144
|
this.configSandboxMode = null;
|
|
131
145
|
this.configSandboxWorkspaceWrite = {};
|
|
146
|
+
this.configModel = null;
|
|
147
|
+
this.configEffort = null;
|
|
148
|
+
this.hasModelOverride = Boolean(options.model);
|
|
149
|
+
this.hasEffortOverride = Boolean(options.effort);
|
|
132
150
|
this.model = options.model || null;
|
|
133
151
|
this.effort = options.effort || null;
|
|
134
152
|
this.models = [];
|
|
@@ -217,9 +235,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
217
235
|
async ensureProcess() {
|
|
218
236
|
if (this.processReady) return this.processReady;
|
|
219
237
|
this.processReady = new Promise((resolve, reject) => {
|
|
220
|
-
const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], {
|
|
221
|
-
cwd: this.workingDir,
|
|
222
|
-
|
|
238
|
+
const child = spawn(this.tool.command, ['app-server', '--listen', 'stdio://'], appServerSpawnOptions({
|
|
239
|
+
cwd: this.workingDir,
|
|
240
|
+
env: { ...process.env }
|
|
241
|
+
}));
|
|
223
242
|
this.process = child;
|
|
224
243
|
const fail = error => {
|
|
225
244
|
this.processReady = null;
|
|
@@ -325,6 +344,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
325
344
|
}
|
|
326
345
|
|
|
327
346
|
handleNotification(method, params) {
|
|
347
|
+
if (method === 'thread/tokenUsage/updated') {
|
|
348
|
+
this.tokenUsage = params.tokenUsage || params.usage || params;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
328
351
|
if (method === 'turn/started') {
|
|
329
352
|
this.currentTurnId = params.turn?.id || params.turnId || this.currentTurnId;
|
|
330
353
|
this.currentTurnStartedAt = Date.now();
|
|
@@ -338,6 +361,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
338
361
|
: params.turn?.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
339
362
|
this.append({ kind: 'turn-end', turnId: completedTurnId, status: turnStatus,
|
|
340
363
|
durationMs: this.currentTurnStartedAt ? Date.now() - this.currentTurnStartedAt : null });
|
|
364
|
+
for (const item of this.messages.filter(message => message.kind === 'tool'
|
|
365
|
+
&& message.turnId === completedTurnId && ['running', 'inProgress'].includes(message.toolStatus))) {
|
|
366
|
+
this.patch(item.id, { toolStatus: turnStatus === 'failed' ? 'failed' : 'completed' });
|
|
367
|
+
}
|
|
341
368
|
for (const pending of this.pendingPermissions.values()) {
|
|
342
369
|
this.recordPermission(pending.public, 'denied', 'abort');
|
|
343
370
|
}
|
|
@@ -401,10 +428,13 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
401
428
|
else this.append({ kind, providerId: itemId, text: delta, streaming: true });
|
|
402
429
|
return;
|
|
403
430
|
}
|
|
404
|
-
if (method.startsWith('item/'))
|
|
431
|
+
if (method.startsWith('item/')) {
|
|
432
|
+
const inferredStatus = method === 'item/completed' ? 'completed' : method === 'item/started' ? 'running' : null;
|
|
433
|
+
this.applyProviderItem(params.item || params, inferredStatus);
|
|
434
|
+
}
|
|
405
435
|
}
|
|
406
436
|
|
|
407
|
-
applyProviderItem(raw) {
|
|
437
|
+
applyProviderItem(raw, inferredStatus = null) {
|
|
408
438
|
if (!raw || typeof raw !== 'object') return;
|
|
409
439
|
const providerId = String(raw.id || '');
|
|
410
440
|
const existing = providerId && this.messages.find(item => item.providerId === providerId);
|
|
@@ -413,8 +443,10 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
413
443
|
if (!kind) return;
|
|
414
444
|
const text = kind === 'user' ? textFromInputItems(raw.content) : kind === 'assistant' ? String(raw.text || '')
|
|
415
445
|
: kind === 'reasoning' ? (raw.text || (Array.isArray(raw.summary) ? raw.summary.join('\n') : Array.isArray(raw.content) ? raw.content.join('\n') : '')) : '';
|
|
446
|
+
const inferredToolStatus = inferredStatus === 'completed' && ['failed', 'declined'].includes(raw.status)
|
|
447
|
+
? raw.status : inferredStatus;
|
|
416
448
|
const patch = kind === 'tool' ? { ...toolDetails(raw), turnId: raw.turnId || this.currentTurnId,
|
|
417
|
-
toolStatus: raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
|
|
449
|
+
toolStatus: inferredToolStatus || raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
|
|
418
450
|
if (existing) {
|
|
419
451
|
this.patch(existing.id, patch);
|
|
420
452
|
} else if (kind === 'user') {
|
|
@@ -432,11 +464,12 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
432
464
|
do {
|
|
433
465
|
const result = await this.request('model/list', { cursor, limit: 100, includeHidden: false });
|
|
434
466
|
for (const item of result?.data || []) models.push({ id: item.id || item.model, label: item.displayName || item.model || item.id,
|
|
435
|
-
efforts: (item.supportedReasoningEfforts || []).map(value => value.reasoningEffort), defaultEffort: item.defaultReasoningEffort || null
|
|
467
|
+
efforts: (item.supportedReasoningEfforts || []).map(value => value.reasoningEffort), defaultEffort: item.defaultReasoningEffort || null,
|
|
468
|
+
isDefault: Boolean(item.isDefault), contextWindow: Number(item.contextWindow || item.context_window || 0) || null });
|
|
436
469
|
cursor = result?.nextCursor || null;
|
|
437
470
|
} while (cursor);
|
|
438
471
|
this.models = models;
|
|
439
|
-
if (!this.model
|
|
472
|
+
if (!this.model) this.model = (models.find(item => item.isDefault) || models[0])?.id || null;
|
|
440
473
|
if (!this.effort) this.effort = models.find(item => item.id === this.model)?.defaultEffort || 'medium';
|
|
441
474
|
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
442
475
|
return models;
|
|
@@ -448,8 +481,12 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
448
481
|
this.configPermissionMode = config.approval_policy || null;
|
|
449
482
|
this.configSandboxMode = normalizeSandboxMode(config.sandbox_mode);
|
|
450
483
|
this.configSandboxWorkspaceWrite = config.sandbox_workspace_write || {};
|
|
484
|
+
this.configModel = config.model || null;
|
|
485
|
+
this.configEffort = config.model_reasoning_effort || null;
|
|
451
486
|
if (!this.permissionMode) this.effectivePermissionMode = this.configPermissionMode;
|
|
452
487
|
if (!this.sandboxMode) this.effectiveSandboxMode = this.configSandboxMode;
|
|
488
|
+
if (!this.hasModelOverride && this.configModel) this.model = this.configModel;
|
|
489
|
+
if (!this.hasEffortOverride && this.configEffort) this.effort = this.configEffort;
|
|
453
490
|
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
454
491
|
return config;
|
|
455
492
|
}
|
|
@@ -474,11 +511,66 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
474
511
|
}));
|
|
475
512
|
}
|
|
476
513
|
|
|
514
|
+
contextStatus() {
|
|
515
|
+
const usage = this.tokenUsage || {};
|
|
516
|
+
const selectedModel = this.models.find(item => item.id === this.model);
|
|
517
|
+
const contextWindow = Number(usage.modelContextWindow || usage.model_context_window
|
|
518
|
+
|| usage.contextWindow || usage.context_window || selectedModel?.contextWindow || 0);
|
|
519
|
+
const last = usage.last || usage.lastTokenUsage || usage.last_token_usage || {};
|
|
520
|
+
const usedTokens = Number(last.totalTokens || last.total_tokens || usage.contextTokens
|
|
521
|
+
|| usage.context_tokens || 0);
|
|
522
|
+
if (!contextWindow) {
|
|
523
|
+
return !this.threadId && !this.tokenUsage
|
|
524
|
+
? { usedTokens: 0, contextWindow: null, remainingTokens: null, remainingPercent: 100 }
|
|
525
|
+
: null;
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
usedTokens: Math.max(0, usedTokens),
|
|
529
|
+
contextWindow,
|
|
530
|
+
remainingTokens: Math.max(0, contextWindow - usedTokens),
|
|
531
|
+
remainingPercent: Math.max(0, Math.min(100, Math.round((contextWindow - usedTokens) / contextWindow * 100)))
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async showStatus() {
|
|
536
|
+
if (this.presentation !== 'structured') return false;
|
|
537
|
+
await this.ensureProcess();
|
|
538
|
+
const accountResult = await this.request('account/read', { refreshToken: false });
|
|
539
|
+
const account = accountResult?.account || null;
|
|
540
|
+
let rateLimits = null;
|
|
541
|
+
if (account?.type === 'chatgpt') {
|
|
542
|
+
try {
|
|
543
|
+
const result = await this.request('account/rateLimits/read', {});
|
|
544
|
+
rateLimits = result?.rateLimits || null;
|
|
545
|
+
} catch (error) {
|
|
546
|
+
this.logger.debugInfo?.(`[codex-app-server] account/rateLimits/read failed: ${error.message}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
this.append({ kind: 'status', title: 'Codex status', model: this.model, effort: this.effort,
|
|
550
|
+
account, rateLimits, context: this.contextStatus() });
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
|
|
477
554
|
async updateSettings(settings = {}) {
|
|
555
|
+
const configEdits = [];
|
|
556
|
+
if (settings.model) configEdits.push({ keyPath: 'model', value: String(settings.model), mergeStrategy: 'upsert' });
|
|
557
|
+
if (settings.effort) configEdits.push({ keyPath: 'model_reasoning_effort', value: String(settings.effort), mergeStrategy: 'upsert' });
|
|
558
|
+
if (configEdits.length) {
|
|
559
|
+
await this.ensureProcess();
|
|
560
|
+
await this.request('config/batchWrite', { edits: configEdits });
|
|
561
|
+
if (settings.model) this.configModel = String(settings.model);
|
|
562
|
+
if (settings.effort) this.configEffort = String(settings.effort);
|
|
563
|
+
}
|
|
478
564
|
if (settings.permissionMode !== undefined) this.permissionMode = normalizePermissionMode(settings.permissionMode);
|
|
479
565
|
if (settings.sandboxMode !== undefined) this.sandboxMode = normalizeSandboxMode(settings.sandboxMode);
|
|
480
|
-
if (settings.model !== undefined)
|
|
481
|
-
|
|
566
|
+
if (settings.model !== undefined) {
|
|
567
|
+
this.hasModelOverride = Boolean(settings.model);
|
|
568
|
+
this.model = settings.model || this.configModel || null;
|
|
569
|
+
}
|
|
570
|
+
if (settings.effort !== undefined) {
|
|
571
|
+
this.hasEffortOverride = Boolean(settings.effort);
|
|
572
|
+
this.effort = settings.effort || this.configEffort || null;
|
|
573
|
+
}
|
|
482
574
|
const needsConfigDefaults = (settings.permissionMode !== undefined && !this.permissionMode)
|
|
483
575
|
|| (settings.sandboxMode !== undefined && !this.sandboxMode);
|
|
484
576
|
if (needsConfigDefaults && this.presentation === 'structured') {
|
|
@@ -497,22 +589,29 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
497
589
|
this.sandboxMode ? {} : this.configSandboxWorkspaceWrite);
|
|
498
590
|
if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
|
|
499
591
|
}
|
|
500
|
-
if (settings.model !== undefined) params.model = this.model;
|
|
501
|
-
if (settings.effort !== undefined) params.effort = this.effort;
|
|
592
|
+
if (settings.model !== undefined) params.model = this.hasModelOverride ? this.model : null;
|
|
593
|
+
if (settings.effort !== undefined) params.effort = this.hasEffortOverride ? this.effort : null;
|
|
502
594
|
if (Object.keys(params).length > 1) await this.request('thread/settings/update', params);
|
|
503
595
|
}
|
|
504
596
|
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
505
597
|
return this.getControlState();
|
|
506
598
|
}
|
|
507
599
|
|
|
508
|
-
async sendUserMessage(text) {
|
|
600
|
+
async sendUserMessage(text, attachments = []) {
|
|
509
601
|
const prompt = String(text || '').trim();
|
|
510
|
-
|
|
602
|
+
const images = (Array.isArray(attachments) ? attachments : [])
|
|
603
|
+
.filter(item => item && typeof item.path === 'string' && item.path);
|
|
604
|
+
if ((!prompt && images.length === 0) || this.presentation !== 'structured' || this.status !== 'idle') return false;
|
|
511
605
|
this.hasUnreadCompletion = false;
|
|
512
|
-
this.append({
|
|
606
|
+
this.append({
|
|
607
|
+
kind: 'user',
|
|
608
|
+
text: prompt || '📷 Image attachment',
|
|
609
|
+
attachments: images.map(item => ({ id: item.id, name: item.name || 'image' }))
|
|
610
|
+
});
|
|
513
611
|
await this.ensureProcess();
|
|
514
612
|
if (!this.threadId) {
|
|
515
|
-
const params = {
|
|
613
|
+
const params = { cwd: this.workingDir };
|
|
614
|
+
if (this.hasModelOverride) params.model = this.model;
|
|
516
615
|
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
517
616
|
if (this.sandboxMode) params.sandbox = this.sandboxMode;
|
|
518
617
|
const started = await this.request('thread/start', params);
|
|
@@ -524,8 +623,12 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
524
623
|
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
525
624
|
}
|
|
526
625
|
this.setStatus('running');
|
|
527
|
-
const
|
|
528
|
-
|
|
626
|
+
const input = [];
|
|
627
|
+
if (prompt) input.push({ type: 'text', text: prompt });
|
|
628
|
+
for (const image of images) input.push({ type: 'localImage', path: image.path });
|
|
629
|
+
const params = { threadId: this.threadId, input, cwd: this.workingDir, summary: 'auto' };
|
|
630
|
+
if (this.hasModelOverride) params.model = this.model;
|
|
631
|
+
if (this.hasEffortOverride) params.effort = this.effort;
|
|
529
632
|
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
530
633
|
const sandboxPolicy = sandboxPolicyFor(this.sandboxMode, this.workingDir);
|
|
531
634
|
if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
|
|
@@ -596,22 +699,36 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
596
699
|
const target = String(threadId || this.threadId || '').trim();
|
|
597
700
|
if (!target || this.presentation !== 'structured' || this.status !== 'idle') return false;
|
|
598
701
|
await this.ensureProcess();
|
|
599
|
-
const params = { threadId: target,
|
|
702
|
+
const params = { threadId: target, cwd: this.workingDir };
|
|
600
703
|
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
601
704
|
if (this.sandboxMode) params.sandbox = this.sandboxMode;
|
|
602
705
|
const result = await this.request('thread/resume', params);
|
|
603
706
|
this.threadId = result.thread?.id || target;
|
|
604
|
-
this.
|
|
707
|
+
this.hasModelOverride = false;
|
|
708
|
+
this.hasEffortOverride = false;
|
|
709
|
+
this.model = result.model || result.thread?.model || this.model;
|
|
710
|
+
this.effort = result.reasoningEffort || result.thread?.reasoningEffort || this.effort;
|
|
605
711
|
this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
|
|
606
712
|
this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
|
|
607
713
|
const history = await this.request('thread/read', { threadId: this.threadId, includeTurns: true });
|
|
714
|
+
this.model = history?.thread?.model || this.model;
|
|
715
|
+
this.effort = history?.thread?.reasoningEffort || history?.thread?.reasoning_effort || this.effort;
|
|
716
|
+
this.tokenUsage = history?.thread?.tokenUsage || history?.thread?.token_usage || this.tokenUsage;
|
|
608
717
|
this.messages = [];
|
|
609
718
|
this.completedPermissions = [];
|
|
610
719
|
for (const turn of history?.thread?.turns || []) {
|
|
611
|
-
this.append({ kind: 'turn-start', turnId: turn.id });
|
|
612
|
-
for (const item of turn.items || []) this.applyProviderItem({ ...item, turnId: turn.id });
|
|
613
720
|
const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
614
|
-
|
|
721
|
+
const startedAt = Number(turn.startedAt || turn.createdAt || 0);
|
|
722
|
+
const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
|
|
723
|
+
const toMilliseconds = value => value > 0 && value < 100000000000 ? value * 1000 : value;
|
|
724
|
+
const startedAtMs = toMilliseconds(startedAt);
|
|
725
|
+
const completedAtMs = toMilliseconds(completedAt);
|
|
726
|
+
this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
|
|
727
|
+
for (const item of turn.items || []) this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed');
|
|
728
|
+
const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
|
|
729
|
+
|| (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
|
|
730
|
+
this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
|
|
731
|
+
...(completedAtMs ? { createdAt: completedAtMs } : {}) });
|
|
615
732
|
}
|
|
616
733
|
this.append({ kind: 'event', level: 'info', text: `Resumed Codex thread ${this.threadId}` });
|
|
617
734
|
this.emitEvent({ type: 'history-reset', messages: this.messages });
|
|
@@ -666,3 +783,4 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
666
783
|
}
|
|
667
784
|
|
|
668
785
|
module.exports = CodexStructuredSession;
|
|
786
|
+
module.exports.appServerSpawnOptions = appServerSpawnOptions;
|
package/lib/commands/web.js
CHANGED
|
@@ -266,6 +266,51 @@ async function webCommand(options) {
|
|
|
266
266
|
res.json({ success: true });
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
// Browser images are stored only in a private, per-session temporary directory.
|
|
270
|
+
// Codex receives the resulting local path through its app-server protocol.
|
|
271
|
+
app.post('/api/sessions/:id/attachments/images', express.raw({ type: () => true, limit: '50mb' }), async (req, res) => {
|
|
272
|
+
try {
|
|
273
|
+
const attachment = await sessionManager.storeCodexImageAttachment(req.params.id, req.body);
|
|
274
|
+
res.status(201).json({ success: true, attachment });
|
|
275
|
+
} catch (e) {
|
|
276
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Mobile Safari can coalesce progress events for a single large request.
|
|
281
|
+
// Small sequential chunks let the browser report progress from server receipts.
|
|
282
|
+
app.post('/api/sessions/:id/attachments/images/chunks', express.raw({ type: () => true, limit: '1mb' }), async (req, res) => {
|
|
283
|
+
try {
|
|
284
|
+
const result = await sessionManager.appendCodexImageChunk(req.params.id, {
|
|
285
|
+
uploadId: req.get('X-Glad-Upload-Id'),
|
|
286
|
+
chunkIndex: req.get('X-Glad-Chunk-Index'),
|
|
287
|
+
chunkTotal: req.get('X-Glad-Chunk-Total')
|
|
288
|
+
}, req.body);
|
|
289
|
+
res.json({ success: true, ...result });
|
|
290
|
+
} catch (e) {
|
|
291
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
app.delete('/api/sessions/:id/attachments/images/uploads/:uploadId', async (req, res) => {
|
|
296
|
+
try {
|
|
297
|
+
const removed = await sessionManager.discardCodexImageUpload(req.params.id, req.params.uploadId);
|
|
298
|
+
res.json({ success: true, removed });
|
|
299
|
+
} catch (e) {
|
|
300
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
app.delete('/api/sessions/:id/attachments/images/:attachmentId', async (req, res) => {
|
|
305
|
+
try {
|
|
306
|
+
const removed = await sessionManager.discardCodexImageAttachment(req.params.id, req.params.attachmentId);
|
|
307
|
+
if (!removed) return res.status(404).json({ error: 'Image attachment not found' });
|
|
308
|
+
res.json({ success: true });
|
|
309
|
+
} catch (e) {
|
|
310
|
+
res.status(e.statusCode || 500).json({ error: e.message });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
269
314
|
// API: Delete/Kill session
|
|
270
315
|
app.delete('/api/sessions/:id', (req, res) => {
|
|
271
316
|
sessionManager.kill(req.params.id);
|
|
@@ -514,7 +559,8 @@ async function webCommand(options) {
|
|
|
514
559
|
sessionManager.abortClaude(sessionId);
|
|
515
560
|
}
|
|
516
561
|
if (payload.type === 'codex-input') {
|
|
517
|
-
sessionManager.
|
|
562
|
+
sessionManager.sendCodexInput(sessionId, payload.text || '', payload.attachmentIds || [])
|
|
563
|
+
.catch(error => logger.error(`Codex input error: ${error.message}`));
|
|
518
564
|
}
|
|
519
565
|
if (payload.type === 'codex-permission') {
|
|
520
566
|
const codex = sessionManager.get(sessionId);
|
|
@@ -525,6 +571,9 @@ async function webCommand(options) {
|
|
|
525
571
|
if (payload.type === 'codex-settings') {
|
|
526
572
|
sessionManager.updateCodexSettings(sessionId, payload.settings || {}).catch(error => logger.error(`Codex settings error: ${error.message}`));
|
|
527
573
|
}
|
|
574
|
+
if (payload.type === 'codex-status') {
|
|
575
|
+
sessionManager.showCodexStatus(sessionId).catch(error => logger.error(`Codex status error: ${error.message}`));
|
|
576
|
+
}
|
|
528
577
|
if (payload.type === 'codex-abort') {
|
|
529
578
|
sessionManager.abortCodex(sessionId);
|
|
530
579
|
}
|
|
@@ -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;
|
|
@@ -245,6 +422,12 @@ class SessionManager extends EventEmitter {
|
|
|
245
422
|
return session.updateSettings(settings || {});
|
|
246
423
|
}
|
|
247
424
|
|
|
425
|
+
showCodexStatus(id) {
|
|
426
|
+
const session = this.get(id);
|
|
427
|
+
if (!session || session.kind !== 'codex-structured') return Promise.resolve(false);
|
|
428
|
+
return session.showStatus();
|
|
429
|
+
}
|
|
430
|
+
|
|
248
431
|
abortCodex(id) {
|
|
249
432
|
const session = this.get(id);
|
|
250
433
|
return session && session.kind === 'codex-structured' ? session.abort('Aborted by user') : false;
|
|
@@ -432,6 +615,8 @@ class SessionManager extends EventEmitter {
|
|
|
432
615
|
if (!session) return false;
|
|
433
616
|
clearTimeout(session.completionTimer);
|
|
434
617
|
this.clearTimedInputs(session);
|
|
618
|
+
this.clearCodexImageUploads(session);
|
|
619
|
+
this.clearCodexImageAttachments(session);
|
|
435
620
|
this.logSessionDiagnostics('session-deleted', session, {}, { compact: true });
|
|
436
621
|
this.disposeSessionHistory(session);
|
|
437
622
|
if (['claude-structured', 'codex-structured'].includes(session.kind)) {
|
|
@@ -448,6 +633,8 @@ class SessionManager extends EventEmitter {
|
|
|
448
633
|
for (const session of this.sessions.values()) {
|
|
449
634
|
clearTimeout(session.completionTimer);
|
|
450
635
|
this.clearTimedInputs(session);
|
|
636
|
+
this.clearCodexImageUploads(session);
|
|
637
|
+
this.clearCodexImageAttachments(session);
|
|
451
638
|
this.disposeSessionHistory(session);
|
|
452
639
|
session.ptyManager.kill();
|
|
453
640
|
}
|
|
@@ -796,6 +983,8 @@ class SessionManager extends EventEmitter {
|
|
|
796
983
|
this.logger.info(`Session ${session.id} (${session.name}) exited.`);
|
|
797
984
|
clearTimeout(session.completionTimer);
|
|
798
985
|
this.clearTimedInputs(session);
|
|
986
|
+
this.clearCodexImageUploads(session);
|
|
987
|
+
this.clearCodexImageAttachments(session);
|
|
799
988
|
this.disposeSessionHistory(session);
|
|
800
989
|
this.sessions.delete(session.id);
|
|
801
990
|
this.emit('exit', { sessionId: session.id, session });
|
|
@@ -838,6 +1027,23 @@ class SessionManager extends EventEmitter {
|
|
|
838
1027
|
session.timedInputs.clear();
|
|
839
1028
|
}
|
|
840
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
|
+
|
|
841
1047
|
getSessionDiagnostics(session, extra = {}) {
|
|
842
1048
|
if (['claude-structured', 'codex-structured'].includes(session.kind)) {
|
|
843
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; }
|
|
@@ -80,9 +97,17 @@
|
|
|
80
97
|
#claude-chat-container { display: none; flex: 1; min-height: 0; overflow-y: auto; background: #050505; padding: 12px 12px 24px 12px; box-sizing: border-box; -webkit-overflow-scrolling: touch; }
|
|
81
98
|
#codex-chat-container { display: none; flex: 1; min-height: 0; overflow-y: auto; background: #050505; padding: 12px 12px 24px 12px; box-sizing: border-box; -webkit-overflow-scrolling: touch; }
|
|
82
99
|
.codex-conversation { width: min(100%, var(--chat-content-max)); margin: 0 auto; }
|
|
100
|
+
.codex-working-indicator { position: sticky; top: 0; z-index: 4; width: 28px; height: 28px; margin: 0 0 -28px auto; border: 1px solid rgba(255,255,255,.1); border-radius: 50%; background: rgba(28,28,30,.68); box-shadow: 0 5px 16px rgba(0,0,0,.24); backdrop-filter: blur(8px); pointer-events: none; }
|
|
101
|
+
.codex-working-indicator::after { content: ''; position: absolute; inset: 8px; border: 1.5px solid rgba(255,255,255,.7); border-top-color: transparent; border-radius: 50%; animation: codex-spin .8s linear infinite; }
|
|
83
102
|
.codex-message { max-width: 100%; margin: 0 0 12px; color: #f5f5f7; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
|
|
84
103
|
.codex-message.user { max-width: 92%; margin-left: auto; padding: 8px 12px; border: 1px solid rgba(0,122,255,.32); border-radius: 12px; background: rgba(0,122,255,.24); }
|
|
85
104
|
.codex-message.event { color: var(--text-dim); text-align: center; font-size: 12px; }
|
|
105
|
+
.codex-status-card { margin: 10px 0 14px; border: 1px solid rgba(100,210,255,.2); border-radius: 10px; background: rgba(28,28,30,.72); padding: 11px; color: #f5f5f7; }
|
|
106
|
+
.codex-status-title { margin-bottom: 9px; color: #64d2ff; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
|
|
107
|
+
.codex-status-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
|
|
108
|
+
.codex-status-item { min-width: 0; border: 1px solid rgba(255,255,255,.07); border-radius: 8px; background: rgba(255,255,255,.035); padding: 8px; }
|
|
109
|
+
.codex-status-label { color: #8e8e93; font-size: 10px; font-weight: 800; text-transform: uppercase; }
|
|
110
|
+
.codex-status-value { margin-top: 3px; color: #f5f5f7; font-size: 12px; font-weight: 700; overflow-wrap: anywhere; }
|
|
86
111
|
.codex-tool { border: 1px solid rgba(255,255,255,.1); border-radius: 8px; background: rgba(255,255,255,.045); overflow: hidden; margin: 8px 0; color: #d1d5db; }
|
|
87
112
|
.codex-tool summary { list-style: none; cursor: pointer; }
|
|
88
113
|
.codex-tool summary::-webkit-details-marker { display: none; }
|
|
@@ -117,8 +142,9 @@
|
|
|
117
142
|
.codex-inline-permission .claude-permission-actions { margin-top: 8px; }
|
|
118
143
|
@keyframes codex-spin { to { transform: rotate(360deg); } }
|
|
119
144
|
#codex-control-panel { display: none; width: min(100%, var(--control-content-max)); margin: 0 auto; padding: 8px 14px 10px; border-bottom: 1px solid #222; background: #121212; box-sizing: border-box; }
|
|
120
|
-
.codex-control-row { display: grid; grid-template-columns: repeat(
|
|
121
|
-
.codex-control-row > * { width: 100%; }
|
|
145
|
+
.codex-control-row { display: grid; grid-template-columns: repeat(6, minmax(0, 116px)); grid-template-rows: auto; justify-content: center; gap: clamp(2px, .8vw, 7px); align-items: center; white-space: nowrap; }
|
|
146
|
+
.codex-control-row > * { width: 100%; min-width: 0; overflow: hidden; }
|
|
147
|
+
.codex-control-row .claude-ctrl-btn, .codex-control-row .codex-select-label { padding-left: clamp(2px, 1vw, 10px); padding-right: clamp(2px, 1vw, 10px); font-size: clamp(8px, 2.2vw, 11px); text-overflow: ellipsis; overflow: hidden; }
|
|
122
148
|
.codex-select-control { position: relative; display: block; min-width: 0; height: 32px; }
|
|
123
149
|
.codex-select-label { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; box-sizing: border-box; border: 1px solid rgba(255,255,255,.1); border-radius: 16px; background: rgba(255,255,255,.08); color: #f5f5f7; font-size: 11px; font-weight: 800; white-space: nowrap; }
|
|
124
150
|
.codex-select-control:focus-within .codex-select-label { background: rgba(255,255,255,.14); border-color: rgba(0,122,255,.45); }
|
|
@@ -293,7 +319,7 @@
|
|
|
293
319
|
#nav-bar > div:last-child .icon-btn { min-width: 34px; min-height: 30px; padding: 5px 7px !important; }
|
|
294
320
|
#input-row { padding-left: 10px; padding-right: 10px; }
|
|
295
321
|
#codex-control-panel { padding-left: 8px; padding-right: 8px; }
|
|
296
|
-
.codex-control-row { grid-template-columns: repeat(
|
|
322
|
+
.codex-control-row { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
|
297
323
|
.codex-control-row .claude-ctrl-btn { height: 36px; padding-left: 4px; padding-right: 4px; }
|
|
298
324
|
.codex-select-control { height: 36px; }
|
|
299
325
|
#codex-chat-container { padding: 10px 10px calc(22px + env(safe-area-inset-bottom)); }
|
|
@@ -367,13 +393,17 @@
|
|
|
367
393
|
<div id="timed-tag-rail"></div>
|
|
368
394
|
<div id="input-row">
|
|
369
395
|
<textarea id="cmd-input" rows="1" placeholder="Type a message..."></textarea>
|
|
370
|
-
<button id="timer-btn" title="
|
|
371
|
-
<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>
|
|
372
|
-
</button>
|
|
396
|
+
<button id="timer-btn" title="Add image or schedule send" aria-label="Add image or schedule send">+</button>
|
|
373
397
|
<button id="send-btn">
|
|
374
398
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
|
375
399
|
</button>
|
|
376
400
|
</div>
|
|
401
|
+
<div id="attachment-strip" aria-live="polite"></div>
|
|
402
|
+
<div id="composer-menu" role="menu">
|
|
403
|
+
<button id="attach-image-btn" class="composer-menu-btn" type="button" role="menuitem">Add image</button>
|
|
404
|
+
<button id="schedule-send-btn" class="composer-menu-btn" type="button" role="menuitem">Schedule send</button>
|
|
405
|
+
</div>
|
|
406
|
+
<input id="image-file-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
|
|
377
407
|
<div id="claude-control-panel">
|
|
378
408
|
<div class="claude-control-row">
|
|
379
409
|
<div class="claude-picker-wrap">
|
|
@@ -425,6 +455,7 @@
|
|
|
425
455
|
</select>
|
|
426
456
|
</label>
|
|
427
457
|
<button id="codex-model-btn" class="claude-ctrl-btn" onclick="toggleCodexModelPanel()" title="Model and effort">Model</button>
|
|
458
|
+
<button id="codex-status-btn" class="claude-ctrl-btn" onclick="requestCodexStatus()" title="Show Codex account, limits, and context status">Status</button>
|
|
428
459
|
<button id="codex-abort-btn" class="claude-ctrl-btn danger" onclick="abortCodexSession()" title="Abort current Codex turn">Abort</button>
|
|
429
460
|
<button id="codex-resume-btn" class="claude-ctrl-btn primary" onclick="toggleCodexResumePanel()" title="Resume a Codex thread">Resume</button>
|
|
430
461
|
</div>
|
|
@@ -932,6 +963,7 @@
|
|
|
932
963
|
document.getElementById('codex-control-panel').style.display = codexChat ? 'block' : 'none';
|
|
933
964
|
document.getElementById('codex-terminal-switch').style.display = isCodexSession() ? '' : 'none';
|
|
934
965
|
document.getElementById('timer-btn').style.display = '';
|
|
966
|
+
document.getElementById('attach-image-btn').style.display = codexChat ? '' : 'none';
|
|
935
967
|
document.getElementById('shortcut-rail').style.display = structured ? 'none' : '';
|
|
936
968
|
document.getElementById('scroll-controls').style.display = structured ? 'none' : '';
|
|
937
969
|
document.getElementById('cmd-input').placeholder = enabled ? 'Message Claude...' : codexChat ? 'Message Codex...' : 'Type a message...';
|
|
@@ -2078,6 +2110,39 @@
|
|
|
2078
2110
|
if (inline) return `<div class="codex-inline-permission">${content}</div>`;
|
|
2079
2111
|
return `<div class="claude-tool claude-permission"><div class="claude-tool-header"><strong>${escapeHtml(request.title || 'Permission required')}</strong></div><div class="claude-tool-body">${content}</div></div>`;
|
|
2080
2112
|
}
|
|
2113
|
+
function formatCodexReset(timestamp) {
|
|
2114
|
+
const value = Number(timestamp || 0);
|
|
2115
|
+
return value ? new Date(value * 1000).toLocaleString() : '';
|
|
2116
|
+
}
|
|
2117
|
+
function formatCodexTokens(value) {
|
|
2118
|
+
const count = Number(value || 0);
|
|
2119
|
+
return count >= 1000000 ? `${(count / 1000000).toFixed(1)}M`
|
|
2120
|
+
: count >= 1000 ? `${Math.round(count / 1000)}K` : String(count);
|
|
2121
|
+
}
|
|
2122
|
+
function codexStatusItem(label, value) {
|
|
2123
|
+
if (value == null || value === '') return '';
|
|
2124
|
+
return `<div class="codex-status-item"><div class="codex-status-label">${escapeHtml(label)}</div><div class="codex-status-value">${escapeHtml(value)}</div></div>`;
|
|
2125
|
+
}
|
|
2126
|
+
function renderCodexStatus(item) {
|
|
2127
|
+
const account = item.account || {};
|
|
2128
|
+
const limit = item.rateLimits || {};
|
|
2129
|
+
const primary = limit.primary;
|
|
2130
|
+
const secondary = limit.secondary;
|
|
2131
|
+
const context = item.context;
|
|
2132
|
+
const accountLabel = account.type === 'chatgpt'
|
|
2133
|
+
? [account.email, account.planType].filter(Boolean).join(' · ')
|
|
2134
|
+
: account.type === 'apiKey' ? 'API key' : account.type || 'Not signed in';
|
|
2135
|
+
const fiveHour = primary ? `${Math.max(0, 100 - Number(primary.usedPercent || 0))}% left${primary.resetsAt ? ` · resets ${formatCodexReset(primary.resetsAt)}` : ''}` : '';
|
|
2136
|
+
const weekly = secondary ? `${Math.max(0, 100 - Number(secondary.usedPercent || 0))}% left${secondary.resetsAt ? ` · resets ${formatCodexReset(secondary.resetsAt)}` : ''}` : '';
|
|
2137
|
+
const contextLabel = context ? `${context.remainingPercent}% left${context.contextWindow ? ` · ${formatCodexTokens(context.remainingTokens)} / ${formatCodexTokens(context.contextWindow)}` : ''}` : 'Available after the first usage update';
|
|
2138
|
+
return `<div class="codex-status-card"><div class="codex-status-title">${escapeHtml(item.title || 'Codex status')}</div><div class="codex-status-grid">
|
|
2139
|
+
${codexStatusItem('Account', accountLabel)}
|
|
2140
|
+
${codexStatusItem('Model', [item.model, item.effort].filter(Boolean).join(' · '))}
|
|
2141
|
+
${codexStatusItem('5h limit', fiveHour)}
|
|
2142
|
+
${codexStatusItem('Weekly limit', weekly)}
|
|
2143
|
+
${codexStatusItem('Context', contextLabel)}
|
|
2144
|
+
</div></div>`;
|
|
2145
|
+
}
|
|
2081
2146
|
function renderCodexTool(item, permission = null) {
|
|
2082
2147
|
const status = codexToolStatus(item);
|
|
2083
2148
|
const isError = status === 'failed' || Boolean(item.error) || (item.exitCode != null && item.exitCode !== 0);
|
|
@@ -2091,17 +2156,22 @@
|
|
|
2091
2156
|
const result = item.result || (item.name === 'McpTool' || item.name === 'Agent' ? codexJson(item.input) : '');
|
|
2092
2157
|
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>`;
|
|
2093
2158
|
}
|
|
2094
|
-
function renderCodexToolGroup(items, permissionById, usedPermissions) {
|
|
2159
|
+
function renderCodexToolGroup(items, permissionById, usedPermissions, turnEnd = null) {
|
|
2095
2160
|
const running = items.some(item => codexToolStatus(item) === 'running');
|
|
2096
2161
|
const failed = items.some(item => codexToolStatus(item) === 'failed' || item.error);
|
|
2097
2162
|
const startedAt = Math.min(...items.map(item => Number(item.createdAt || Date.now())));
|
|
2098
|
-
const
|
|
2163
|
+
const storedDuration = Number(turnEnd?.durationMs || 0);
|
|
2164
|
+
const completedAt = Number(turnEnd?.createdAt || 0);
|
|
2165
|
+
const durationMs = storedDuration > 0 ? storedDuration
|
|
2166
|
+
: (!running && completedAt >= startedAt ? completedAt - startedAt : 0);
|
|
2167
|
+
const seconds = durationMs > 0 ? Math.max(1, Math.round(durationMs / 1000)) : null;
|
|
2099
2168
|
const tools = items.map(item => {
|
|
2100
2169
|
const permission = permissionById.get(String(item.providerId || ''));
|
|
2101
2170
|
if (permission) usedPermissions.add(permission.id);
|
|
2102
2171
|
return renderCodexTool(item, permission);
|
|
2103
2172
|
}).join('');
|
|
2104
|
-
|
|
2173
|
+
const label = running ? 'Working' : failed ? 'Work finished with errors' : seconds ? `Worked for ${seconds}s` : 'Worked';
|
|
2174
|
+
return `<details class="codex-work-group"${running ? ' open' : ''}><summary>${label} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
|
|
2105
2175
|
}
|
|
2106
2176
|
function renderCodexChat() {
|
|
2107
2177
|
const container = document.getElementById('codex-chat-container');
|
|
@@ -2109,13 +2179,17 @@
|
|
|
2109
2179
|
const parts = [];
|
|
2110
2180
|
const permissionById = new Map(codexPendingPermissions.map(item => [String(item.id), item]));
|
|
2111
2181
|
const usedPermissions = new Set();
|
|
2182
|
+
const turnEndById = new Map(codexMessages.filter(item => item.kind === 'turn-end' && item.turnId)
|
|
2183
|
+
.map(item => [String(item.turnId), item]));
|
|
2112
2184
|
const visible = codexMessages.filter(item => !['reasoning', 'turn-start', 'turn-end'].includes(item.kind));
|
|
2113
2185
|
for (let i = 0; i < visible.length;) {
|
|
2114
2186
|
const item = visible[i];
|
|
2115
2187
|
if (item.kind === 'tool') {
|
|
2116
2188
|
const tools = [];
|
|
2117
|
-
|
|
2118
|
-
|
|
2189
|
+
const turnId = item.turnId;
|
|
2190
|
+
while (i < visible.length && visible[i].kind === 'tool' && visible[i].turnId === turnId) tools.push(visible[i++]);
|
|
2191
|
+
if (tools.length > 1) parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions,
|
|
2192
|
+
turnEndById.get(String(turnId || ''))));
|
|
2119
2193
|
else {
|
|
2120
2194
|
const permission = permissionById.get(String(tools[0].providerId || ''));
|
|
2121
2195
|
if (permission) usedPermissions.add(permission.id);
|
|
@@ -2125,14 +2199,14 @@
|
|
|
2125
2199
|
}
|
|
2126
2200
|
if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant claude-md">${renderMarkdown(item.text || '')}</div>`);
|
|
2127
2201
|
else if (item.kind === 'user') parts.push(`<div class="codex-message user claude-md">${renderMarkdown(item.text || '')}</div>`);
|
|
2202
|
+
else if (item.kind === 'status') parts.push(renderCodexStatus(item));
|
|
2128
2203
|
else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
|
|
2129
2204
|
i += 1;
|
|
2130
2205
|
}
|
|
2131
2206
|
for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
}
|
|
2135
|
-
container.innerHTML = `<div class="codex-conversation">${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
|
|
2207
|
+
const working = codexState.status === 'running'
|
|
2208
|
+
? '<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"></div>' : '';
|
|
2209
|
+
container.innerHTML = `<div class="codex-conversation">${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
|
|
2136
2210
|
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
|
2137
2211
|
}
|
|
2138
2212
|
|
|
@@ -2175,7 +2249,6 @@
|
|
|
2175
2249
|
const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
|
|
2176
2250
|
const parts = [];
|
|
2177
2251
|
if (pending || codexState.status === 'waiting_approval') parts.push(`<span class="claude-state-pill warn">${pending || 1} approval${pending === 1 ? '' : 's'}</span>`);
|
|
2178
|
-
else if (codexState.status === 'running') parts.push('<span class="claude-state-pill">Working</span>');
|
|
2179
2252
|
el.innerHTML = parts.join('');
|
|
2180
2253
|
el.style.display = parts.length ? 'flex' : 'none';
|
|
2181
2254
|
}
|
|
@@ -2228,6 +2301,7 @@
|
|
|
2228
2301
|
sendCodexSettings({ permissionMode, sandboxMode });
|
|
2229
2302
|
}
|
|
2230
2303
|
function abortCodexSession() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-abort' })); }
|
|
2304
|
+
function requestCodexStatus() { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-status' })); }
|
|
2231
2305
|
function respondCodexPermission(id, decision) { if (currentSocket?.readyState === 1) currentSocket.send(JSON.stringify({ type: 'codex-permission', id, decision })); }
|
|
2232
2306
|
async function toggleCodexResumePanel() {
|
|
2233
2307
|
codexResumePanelOpen = !codexResumePanelOpen;
|
|
@@ -2260,6 +2334,9 @@
|
|
|
2260
2334
|
}
|
|
2261
2335
|
|
|
2262
2336
|
function joinSession(id, sessionName, toolKey = null) {
|
|
2337
|
+
if (typeof clearImageAttachments === 'function' && selectedImageAttachments.length) {
|
|
2338
|
+
void clearImageAttachments();
|
|
2339
|
+
}
|
|
2263
2340
|
stopTimedInputTimers();
|
|
2264
2341
|
activeSessionId = id;
|
|
2265
2342
|
window.activeSessionId = id;
|
|
@@ -2603,8 +2680,177 @@
|
|
|
2603
2680
|
}
|
|
2604
2681
|
|
|
2605
2682
|
const inputEl = document.getElementById('cmd-input');
|
|
2683
|
+
const imageFileInput = document.getElementById('image-file-input');
|
|
2684
|
+
const attachmentStrip = document.getElementById('attachment-strip');
|
|
2685
|
+
let selectedImageAttachments = [];
|
|
2606
2686
|
let keepTerminalBottomForNextInput = false;
|
|
2607
2687
|
|
|
2688
|
+
function isCodexImageAttachmentAvailable() {
|
|
2689
|
+
return isCodexSession() && codexState.presentation === 'structured';
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
function syncComposerButtonState() {
|
|
2693
|
+
const menuOpen = document.getElementById('composer-menu').classList.contains('active');
|
|
2694
|
+
const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
|
|
2695
|
+
document.getElementById('timer-btn').classList.toggle('active', menuOpen || timerOpen);
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
function renderImageAttachments() {
|
|
2699
|
+
attachmentStrip.innerHTML = selectedImageAttachments.map(item => (
|
|
2700
|
+
`<div class="attachment-chip${item.uploading ? ' uploading' : ''}"><span aria-hidden="true">▧</span><span class="attachment-chip-content"><span class="attachment-chip-name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</span>${item.uploading ? `<span class="attachment-progress${item.progressKnown ? '' : ' estimated'}"><span style="width:${Math.max(0, Math.min(100, item.progress || 0))}%"></span></span><span class="attachment-status">${escapeHtml(item.status || (item.progressKnown ? `Uploading ${Math.round(item.progress || 0)}%` : 'Uploading original image…'))}</span>` : ''}</span><button class="attachment-remove" type="button" title="Remove image" aria-label="Remove ${escapeHtml(item.name)}" onclick="removeImageAttachment('${item.id}')">×</button></div>`
|
|
2701
|
+
)).join('');
|
|
2702
|
+
attachmentStrip.classList.toggle('active', selectedImageAttachments.length > 0);
|
|
2703
|
+
updateTerminalControlsHeight();
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
window.removeImageAttachment = async function(attachmentId) {
|
|
2707
|
+
const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
|
|
2708
|
+
selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
|
|
2709
|
+
renderImageAttachments();
|
|
2710
|
+
if (!attachment) return;
|
|
2711
|
+
clearInterval(attachment.indicatorTimer);
|
|
2712
|
+
attachment.abortUpload?.();
|
|
2713
|
+
if (attachment.uploading) return;
|
|
2714
|
+
try {
|
|
2715
|
+
await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
2716
|
+
} catch (_) {
|
|
2717
|
+
// The server also removes all attachments when the session ends.
|
|
2718
|
+
}
|
|
2719
|
+
};
|
|
2720
|
+
|
|
2721
|
+
async function clearImageAttachments() {
|
|
2722
|
+
const pending = selectedImageAttachments;
|
|
2723
|
+
selectedImageAttachments = [];
|
|
2724
|
+
renderImageAttachments();
|
|
2725
|
+
for (const item of pending) item.abortUpload?.();
|
|
2726
|
+
await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
|
|
2727
|
+
`/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
|
|
2728
|
+
{ method: 'DELETE' }
|
|
2729
|
+
).catch(() => null)));
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
const IMAGE_UPLOAD_CHUNK_BYTES = 512 * 1024;
|
|
2733
|
+
|
|
2734
|
+
function uploadImageInChunks(sessionId, file, onProgress) {
|
|
2735
|
+
let xhr = null;
|
|
2736
|
+
let cancelled = false;
|
|
2737
|
+
const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
2738
|
+
const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
|
|
2739
|
+
return {
|
|
2740
|
+
abort: () => {
|
|
2741
|
+
cancelled = true;
|
|
2742
|
+
xhr?.abort();
|
|
2743
|
+
void fetch(`/api/sessions/${sessionId}/attachments/images/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
|
|
2744
|
+
},
|
|
2745
|
+
promise: (async () => {
|
|
2746
|
+
for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
|
|
2747
|
+
if (cancelled) throw new Error('Image upload cancelled');
|
|
2748
|
+
const start = chunkIndex * IMAGE_UPLOAD_CHUNK_BYTES;
|
|
2749
|
+
const chunk = file.slice(start, Math.min(file.size, start + IMAGE_UPLOAD_CHUNK_BYTES));
|
|
2750
|
+
const result = await new Promise((resolve, reject) => {
|
|
2751
|
+
xhr = new XMLHttpRequest();
|
|
2752
|
+
xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
|
|
2753
|
+
xhr.timeout = 60_000;
|
|
2754
|
+
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
|
2755
|
+
xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
|
|
2756
|
+
xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
|
|
2757
|
+
xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
|
|
2758
|
+
xhr.onerror = () => reject(new Error('Network error while uploading image'));
|
|
2759
|
+
xhr.ontimeout = () => reject(new Error(`Image upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
|
|
2760
|
+
xhr.onabort = () => reject(new Error('Image upload cancelled'));
|
|
2761
|
+
xhr.onload = () => {
|
|
2762
|
+
let data = {};
|
|
2763
|
+
try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
|
|
2764
|
+
if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
|
|
2765
|
+
reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
resolve(data);
|
|
2769
|
+
};
|
|
2770
|
+
xhr.send(chunk);
|
|
2771
|
+
});
|
|
2772
|
+
const confirmedBytes = Math.min(file.size, start + chunk.size);
|
|
2773
|
+
onProgress(Math.round((confirmedBytes / file.size) * 100), chunkIndex + 1, chunkTotal);
|
|
2774
|
+
if (result.complete) return result.attachment;
|
|
2775
|
+
}
|
|
2776
|
+
throw new Error('Image upload did not complete');
|
|
2777
|
+
})()
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
async function uploadImageFiles(files) {
|
|
2782
|
+
if (!isCodexImageAttachmentAvailable()) {
|
|
2783
|
+
alert('Image attachments are available only in Codex chat mode.');
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
const remaining = 5 - selectedImageAttachments.length;
|
|
2787
|
+
const batch = Array.from(files).slice(0, remaining);
|
|
2788
|
+
if (files.length > remaining) alert('You can attach up to 5 images at a time.');
|
|
2789
|
+
for (const file of batch) {
|
|
2790
|
+
if (file.size > 50 * 1024 * 1024) {
|
|
2791
|
+
alert(`${file.name} is larger than 50 MB.`);
|
|
2792
|
+
continue;
|
|
2793
|
+
}
|
|
2794
|
+
const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
|
|
2795
|
+
const pending = {
|
|
2796
|
+
id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
|
|
2797
|
+
name: file.name || 'image',
|
|
2798
|
+
sessionId: activeSessionId,
|
|
2799
|
+
uploading: true,
|
|
2800
|
+
progress: 0,
|
|
2801
|
+
progressKnown: true,
|
|
2802
|
+
status: '0%',
|
|
2803
|
+
abortUpload: null
|
|
2804
|
+
};
|
|
2805
|
+
selectedImageAttachments.push(pending);
|
|
2806
|
+
renderImageAttachments();
|
|
2807
|
+
try {
|
|
2808
|
+
const upload = uploadImageInChunks(activeSessionId, file, progress => {
|
|
2809
|
+
pending.progress = progress;
|
|
2810
|
+
pending.status = `${progress}%`;
|
|
2811
|
+
renderImageAttachments();
|
|
2812
|
+
});
|
|
2813
|
+
pending.abortUpload = upload.abort;
|
|
2814
|
+
const attachment = await upload.promise;
|
|
2815
|
+
const index = selectedImageAttachments.indexOf(pending);
|
|
2816
|
+
if (index < 0) {
|
|
2817
|
+
await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/images/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
|
|
2818
|
+
continue;
|
|
2819
|
+
}
|
|
2820
|
+
selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
|
|
2821
|
+
renderImageAttachments();
|
|
2822
|
+
} catch (e) {
|
|
2823
|
+
selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
|
|
2824
|
+
renderImageAttachments();
|
|
2825
|
+
if (e.message === 'Image upload cancelled') continue;
|
|
2826
|
+
alert(`Could not add ${file.name}: ${e.message}`);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
function closeComposerMenu() {
|
|
2832
|
+
document.getElementById('composer-menu').classList.remove('active');
|
|
2833
|
+
syncComposerButtonState();
|
|
2834
|
+
updateTerminalControlsHeight();
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
function openTimedSendPanel() {
|
|
2838
|
+
closeComposerMenu();
|
|
2839
|
+
if (isClaudeSession()) {
|
|
2840
|
+
closeClaudePicker();
|
|
2841
|
+
closeClaudeUsagePanel();
|
|
2842
|
+
claudeResumePanelOpen = false;
|
|
2843
|
+
document.getElementById('claude-resume-panel').classList.remove('active');
|
|
2844
|
+
}
|
|
2845
|
+
initTimedDelaySelectors();
|
|
2846
|
+
resetTimedEditor({ keepInput: true });
|
|
2847
|
+
document.getElementById('timed-send-panel').classList.add('active');
|
|
2848
|
+
updateTimedSendPreview();
|
|
2849
|
+
loadTimedInputs();
|
|
2850
|
+
syncComposerButtonState();
|
|
2851
|
+
updateTerminalControlsHeight();
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2608
2854
|
function markInputEditStart() {
|
|
2609
2855
|
keepTerminalBottomForNextInput = keepTerminalBottomForNextInput || isTerminalAtBottom();
|
|
2610
2856
|
}
|
|
@@ -2620,6 +2866,11 @@
|
|
|
2620
2866
|
|
|
2621
2867
|
function performSend() {
|
|
2622
2868
|
const val = inputEl.value;
|
|
2869
|
+
const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
|
|
2870
|
+
if (selectedImageAttachments.some(item => item.uploading)) {
|
|
2871
|
+
alert('Wait for image uploads to finish before sending.');
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2623
2874
|
if (val && isClaudeSession()) {
|
|
2624
2875
|
if (currentSocket && currentSocket.readyState === 1) {
|
|
2625
2876
|
currentSocket.send(JSON.stringify({ type: 'claude-input', text: val }));
|
|
@@ -2628,10 +2879,18 @@
|
|
|
2628
2879
|
inputEl.style.height = '38px';
|
|
2629
2880
|
return;
|
|
2630
2881
|
}
|
|
2631
|
-
if (val && isCodexSession() && codexState.presentation === 'structured') {
|
|
2632
|
-
if (currentSocket && currentSocket.readyState === 1)
|
|
2882
|
+
if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
|
|
2883
|
+
if (currentSocket && currentSocket.readyState === 1) {
|
|
2884
|
+
currentSocket.send(JSON.stringify({
|
|
2885
|
+
type: 'codex-input',
|
|
2886
|
+
text: val,
|
|
2887
|
+
attachmentIds: readyImageAttachments.map(item => item.id)
|
|
2888
|
+
}));
|
|
2889
|
+
}
|
|
2633
2890
|
inputEl.value = '';
|
|
2634
2891
|
inputEl.style.height = '38px';
|
|
2892
|
+
selectedImageAttachments = [];
|
|
2893
|
+
renderImageAttachments();
|
|
2635
2894
|
return;
|
|
2636
2895
|
}
|
|
2637
2896
|
if (val) {
|
|
@@ -2710,9 +2969,9 @@
|
|
|
2710
2969
|
|
|
2711
2970
|
function closeTimedSendPanel() {
|
|
2712
2971
|
document.getElementById('timed-send-panel').classList.remove('active');
|
|
2713
|
-
document.getElementById('timer-btn').classList.remove('active');
|
|
2714
2972
|
editingTimedInputId = null;
|
|
2715
2973
|
renderTimedTags();
|
|
2974
|
+
syncComposerButtonState();
|
|
2716
2975
|
updateTerminalControlsHeight();
|
|
2717
2976
|
}
|
|
2718
2977
|
|
|
@@ -2761,6 +3020,7 @@
|
|
|
2761
3020
|
function editTimedInput(id) {
|
|
2762
3021
|
const item = timedInputs.find(value => value.id === id);
|
|
2763
3022
|
if (!item) return;
|
|
3023
|
+
closeComposerMenu();
|
|
2764
3024
|
initTimedDelaySelectors();
|
|
2765
3025
|
editingTimedInputId = id;
|
|
2766
3026
|
inputEl.value = item.text || '';
|
|
@@ -2771,9 +3031,9 @@
|
|
|
2771
3031
|
document.getElementById('timed-cancel-edit-btn').style.display = '';
|
|
2772
3032
|
document.getElementById('timed-delete-btn').style.display = '';
|
|
2773
3033
|
document.getElementById('timed-send-panel').classList.add('active');
|
|
2774
|
-
document.getElementById('timer-btn').classList.add('active');
|
|
2775
3034
|
updateTimedSendPreview();
|
|
2776
3035
|
renderTimedTags();
|
|
3036
|
+
syncComposerButtonState();
|
|
2777
3037
|
updateTerminalControlsHeight();
|
|
2778
3038
|
}
|
|
2779
3039
|
|
|
@@ -2819,24 +3079,23 @@
|
|
|
2819
3079
|
|
|
2820
3080
|
document.getElementById('send-btn').addEventListener('click', performSend);
|
|
2821
3081
|
document.getElementById('timer-btn').addEventListener('click', () => {
|
|
2822
|
-
const
|
|
2823
|
-
const
|
|
2824
|
-
document.getElementById('
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
closeClaudePicker();
|
|
2828
|
-
closeClaudeUsagePanel();
|
|
2829
|
-
claudeResumePanelOpen = false;
|
|
2830
|
-
document.getElementById('claude-resume-panel').classList.remove('active');
|
|
2831
|
-
}
|
|
2832
|
-
initTimedDelaySelectors();
|
|
2833
|
-
resetTimedEditor({ keepInput: true });
|
|
2834
|
-
updateTimedSendPreview();
|
|
2835
|
-
loadTimedInputs();
|
|
2836
|
-
}
|
|
2837
|
-
else closeTimedSendPanel();
|
|
3082
|
+
const menu = document.getElementById('composer-menu');
|
|
3083
|
+
const willOpen = !menu.classList.contains('active');
|
|
3084
|
+
document.getElementById('timed-send-panel').classList.remove('active');
|
|
3085
|
+
menu.classList.toggle('active', willOpen);
|
|
3086
|
+
syncComposerButtonState();
|
|
2838
3087
|
updateTerminalControlsHeight();
|
|
2839
3088
|
});
|
|
3089
|
+
document.getElementById('attach-image-btn').addEventListener('click', () => {
|
|
3090
|
+
closeComposerMenu();
|
|
3091
|
+
imageFileInput.click();
|
|
3092
|
+
});
|
|
3093
|
+
document.getElementById('schedule-send-btn').addEventListener('click', openTimedSendPanel);
|
|
3094
|
+
imageFileInput.addEventListener('change', () => {
|
|
3095
|
+
const files = imageFileInput.files;
|
|
3096
|
+
if (files?.length) void uploadImageFiles(files);
|
|
3097
|
+
imageFileInput.value = '';
|
|
3098
|
+
});
|
|
2840
3099
|
inputEl.addEventListener('keydown', (e) => {
|
|
2841
3100
|
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); performSend(); }
|
|
2842
3101
|
else if (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glad-web",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.25",
|
|
4
4
|
"description": "Glad transforms terminal-based AI coding tools into a polished, mobile-friendly local Web interface.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"conf": "^10.2.0",
|
|
53
53
|
"express": "^5.2.1",
|
|
54
54
|
"node-pty": "npm:@lydell/node-pty@^1.1.0",
|
|
55
|
-
"uuid": "^
|
|
55
|
+
"uuid": "^11.1.1",
|
|
56
56
|
"ws": "^8.19.0",
|
|
57
|
-
"xterm
|
|
57
|
+
"@xterm/headless": "^6.0.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"caxa": "^3.0.1"
|