glad-web 1.0.27 → 1.0.29
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 +70 -37
- package/lib/session/session-manager.js +8 -27
- package/lib/web/index.html +91 -18
- package/package.json +1 -1
|
@@ -43,6 +43,12 @@ function safeJson(value) {
|
|
|
43
43
|
try { return JSON.stringify(value || {}, null, 2); } catch (_) { return String(value || ''); }
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function toTimestampMs(value) {
|
|
47
|
+
const timestamp = Number(value || 0);
|
|
48
|
+
if (!timestamp) return null;
|
|
49
|
+
return timestamp < 100000000000 ? timestamp * 1000 : timestamp;
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
function appServerSpawnOptions({ cwd, env, platform = process.platform }) {
|
|
47
53
|
const options = { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] };
|
|
48
54
|
|
|
@@ -161,6 +167,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
161
167
|
this.currentTurnId = null;
|
|
162
168
|
this.currentTurnStartedAt = null;
|
|
163
169
|
this.threadTurns = new Map();
|
|
170
|
+
this.providerItemContexts = new Map();
|
|
164
171
|
this.tokenUsage = null;
|
|
165
172
|
this.permissionMode = normalizePermissionMode(options.permissionMode);
|
|
166
173
|
this.sandboxMode = normalizeSandboxMode(options.sandboxMode);
|
|
@@ -247,7 +254,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
247
254
|
patch(id, patch) {
|
|
248
255
|
const item = this.messages.find(message => message.id === id);
|
|
249
256
|
if (!item) return null;
|
|
250
|
-
Object.assign(item, patch);
|
|
257
|
+
Object.assign(item, { updatedAt: Date.now() }, patch);
|
|
251
258
|
this.emitEvent({ type: 'message-updated', message: item });
|
|
252
259
|
return item;
|
|
253
260
|
}
|
|
@@ -475,17 +482,27 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
475
482
|
if (method === 'item/plan/delta') {
|
|
476
483
|
const providerId = String(params.itemId || '');
|
|
477
484
|
const target = this.messages.find(item => item.providerId === providerId && item.kind === 'reasoning');
|
|
478
|
-
|
|
479
|
-
|
|
485
|
+
const known = this.providerItemContexts.get(providerId) || {};
|
|
486
|
+
const threadId = params.threadId || target?.threadId || known.threadId || null;
|
|
487
|
+
const turnId = params.turnId || target?.turnId || known.turnId
|
|
488
|
+
|| (threadId ? this.threadTurns.get(threadId)?.turnId : null) || this.currentTurnId;
|
|
489
|
+
if (providerId && (threadId || turnId)) this.providerItemContexts.set(providerId, { threadId, turnId });
|
|
490
|
+
if (target) this.patch(target.id, { text: String(target.text || '') + String(params.delta || ''), threadId, turnId });
|
|
491
|
+
else this.append({ kind: 'reasoning', providerId, text: String(params.delta || ''), threadId, turnId, streaming: true });
|
|
480
492
|
return;
|
|
481
493
|
}
|
|
482
494
|
if (method.includes('agentMessage/delta') || method.includes('reasoning/textDelta') || method.includes('reasoning/summaryTextDelta')) {
|
|
483
495
|
const kind = method.includes('agentMessage') ? 'assistant' : 'reasoning';
|
|
484
496
|
const itemId = String(params.itemId || params.id || '');
|
|
485
497
|
const target = this.messages.find(item => item.providerId === itemId && item.kind === kind);
|
|
498
|
+
const known = this.providerItemContexts.get(itemId) || {};
|
|
499
|
+
const threadId = params.threadId || target?.threadId || known.threadId || null;
|
|
500
|
+
const turnId = params.turnId || target?.turnId || known.turnId
|
|
501
|
+
|| (threadId ? this.threadTurns.get(threadId)?.turnId : null) || this.currentTurnId;
|
|
486
502
|
const delta = String(params.delta || '');
|
|
487
|
-
if (
|
|
488
|
-
|
|
503
|
+
if (itemId && (threadId || turnId)) this.providerItemContexts.set(itemId, { threadId, turnId });
|
|
504
|
+
if (target) this.patch(target.id, { text: (target.text || '') + delta, threadId, turnId });
|
|
505
|
+
else this.append({ kind, providerId: itemId, text: delta, threadId, turnId, streaming: true });
|
|
489
506
|
return;
|
|
490
507
|
}
|
|
491
508
|
if (method.startsWith('item/')) {
|
|
@@ -513,9 +530,12 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
513
530
|
const threadId = raw.threadId || context.threadId || null;
|
|
514
531
|
const trackedTurnId = threadId ? this.threadTurns.get(threadId)?.turnId : null;
|
|
515
532
|
const turnId = raw.turnId || context.turnId || trackedTurnId || this.currentTurnId;
|
|
533
|
+
if (providerId && (threadId || turnId)) this.providerItemContexts.set(providerId, { threadId, turnId });
|
|
516
534
|
const existingStartedAtMs = existing?.startedAtMs || existing?.createdAt || null;
|
|
517
|
-
const startedAtMs = context.startedAtMs || raw.startedAtMs ||
|
|
518
|
-
|
|
535
|
+
const startedAtMs = Number(context.startedAtMs || raw.startedAtMs || 0)
|
|
536
|
+
|| toTimestampMs(raw.startedAt || raw.createdAt) || existingStartedAtMs;
|
|
537
|
+
const completedAtMs = Number(context.completedAtMs || raw.completedAtMs || 0)
|
|
538
|
+
|| toTimestampMs(raw.completedAt || raw.updatedAt);
|
|
519
539
|
const durationMs = Number(raw.durationMs || 0)
|
|
520
540
|
|| (completedAtMs && startedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null)
|
|
521
541
|
|| Number(existing?.durationMs || 0) || null;
|
|
@@ -526,13 +546,13 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
526
546
|
};
|
|
527
547
|
const patch = kind === 'tool' ? { ...toolDetails(raw), threadId, turnId,
|
|
528
548
|
...timing, toolStatus: inferredToolStatus || raw.status || 'running' }
|
|
529
|
-
: { text, threadId, turnId, streaming: false };
|
|
549
|
+
: { text, threadId, turnId, streaming: false, ...(completedAtMs ? { completedAtMs } : {}) };
|
|
530
550
|
if (existing) {
|
|
531
551
|
this.patch(existing.id, patch);
|
|
532
552
|
} else if (kind === 'user') {
|
|
533
553
|
const local = [...this.messages].reverse().find(item => item.kind === 'user' && !item.providerId && item.text === text);
|
|
534
554
|
if (local) this.patch(local.id, { providerId, ...patch });
|
|
535
|
-
else this.append({ kind, providerId, ...patch });
|
|
555
|
+
else this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
|
|
536
556
|
} else {
|
|
537
557
|
this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
|
|
538
558
|
}
|
|
@@ -702,33 +722,41 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
702
722
|
text: prompt || '📷 Image attachment',
|
|
703
723
|
attachments: images.map(item => ({ id: item.id, name: item.name || 'image' }))
|
|
704
724
|
});
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
725
|
+
try {
|
|
726
|
+
await this.ensureProcess();
|
|
727
|
+
if (!this.threadId) {
|
|
728
|
+
const params = { cwd: this.workingDir };
|
|
729
|
+
if (this.hasModelOverride) params.model = this.model;
|
|
730
|
+
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
731
|
+
if (this.sandboxMode) params.sandbox = this.sandboxMode;
|
|
732
|
+
const started = await this.request('thread/start', params);
|
|
733
|
+
this.threadId = started.thread?.id;
|
|
734
|
+
this.model = started.model || this.model;
|
|
735
|
+
this.effort = started.reasoningEffort || this.effort;
|
|
736
|
+
this.effectivePermissionMode = started.approvalPolicy || this.effectivePermissionMode;
|
|
737
|
+
this.effectiveSandboxMode = sandboxModeFromPolicy(started.sandbox) || this.effectiveSandboxMode;
|
|
738
|
+
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
739
|
+
}
|
|
740
|
+
this.setStatus('running');
|
|
741
|
+
const input = [];
|
|
742
|
+
if (prompt) input.push({ type: 'text', text: prompt });
|
|
743
|
+
for (const image of images) input.push({ type: 'localImage', path: image.path });
|
|
744
|
+
const params = { threadId: this.threadId, input, cwd: this.workingDir, summary: 'auto' };
|
|
708
745
|
if (this.hasModelOverride) params.model = this.model;
|
|
746
|
+
if (this.hasEffortOverride) params.effort = this.effort;
|
|
709
747
|
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
this.
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
this.
|
|
717
|
-
this.
|
|
748
|
+
const sandboxPolicy = sandboxPolicyFor(this.sandboxMode, this.workingDir);
|
|
749
|
+
if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
|
|
750
|
+
const started = await this.request('turn/start', params);
|
|
751
|
+
this.currentTurnId = started?.turn?.id || started?.turnId || this.currentTurnId;
|
|
752
|
+
return true;
|
|
753
|
+
} catch (error) {
|
|
754
|
+
this.currentTurnId = null;
|
|
755
|
+
this.currentTurnStartedAt = null;
|
|
756
|
+
this.setStatus('idle');
|
|
757
|
+
this.append({ kind: 'event', level: 'error', text: `Unable to send message: ${error.message}` });
|
|
758
|
+
throw error;
|
|
718
759
|
}
|
|
719
|
-
this.setStatus('running');
|
|
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' };
|
|
724
|
-
if (this.hasModelOverride) params.model = this.model;
|
|
725
|
-
if (this.hasEffortOverride) params.effort = this.effort;
|
|
726
|
-
if (this.permissionMode) params.approvalPolicy = this.permissionMode;
|
|
727
|
-
const sandboxPolicy = sandboxPolicyFor(this.sandboxMode, this.workingDir);
|
|
728
|
-
if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
|
|
729
|
-
const started = await this.request('turn/start', params);
|
|
730
|
-
this.currentTurnId = started?.turn?.id || started?.turnId || this.currentTurnId;
|
|
731
|
-
return true;
|
|
732
760
|
}
|
|
733
761
|
|
|
734
762
|
write(data) {
|
|
@@ -736,9 +764,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
736
764
|
const text = String(data || '').replace(/\r/g, '\n');
|
|
737
765
|
const prompt = text.trim();
|
|
738
766
|
if (prompt) void this.sendUserMessage(prompt).catch(error => {
|
|
739
|
-
this.
|
|
740
|
-
this.setStatus('idle');
|
|
741
|
-
this.append({ kind: 'event', level: 'error', text: error.message });
|
|
767
|
+
this.logger.debugInfo?.(`[codex-app-server] send failed: ${error.message}`);
|
|
742
768
|
});
|
|
743
769
|
return true;
|
|
744
770
|
}
|
|
@@ -822,6 +848,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
822
848
|
this.tokenUsage = thread?.tokenUsage || thread?.token_usage || this.tokenUsage;
|
|
823
849
|
this.messages = [];
|
|
824
850
|
this.completedPermissions = [];
|
|
851
|
+
this.providerItemContexts.clear();
|
|
825
852
|
for (const turn of thread?.turns || []) {
|
|
826
853
|
const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
827
854
|
const startedAt = Number(turn.startedAt || turn.createdAt || 0);
|
|
@@ -830,7 +857,13 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
830
857
|
const startedAtMs = toMilliseconds(startedAt);
|
|
831
858
|
const completedAtMs = toMilliseconds(completedAt);
|
|
832
859
|
this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
|
|
833
|
-
for (const item of turn.items || [])
|
|
860
|
+
for (const item of turn.items || []) {
|
|
861
|
+
this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed', {
|
|
862
|
+
threadId: this.threadId,
|
|
863
|
+
startedAtMs,
|
|
864
|
+
completedAtMs
|
|
865
|
+
});
|
|
866
|
+
}
|
|
834
867
|
const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
|
|
835
868
|
|| (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
|
|
836
869
|
this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
|
|
@@ -439,42 +439,23 @@ class SessionManager extends EventEmitter {
|
|
|
439
439
|
}
|
|
440
440
|
|
|
441
441
|
async forkCodex(id, threadId) {
|
|
442
|
-
const
|
|
443
|
-
if (!
|
|
444
|
-
if (
|
|
442
|
+
const session = this.get(id);
|
|
443
|
+
if (!session || session.kind !== 'codex-structured') return null;
|
|
444
|
+
if (session.presentation !== 'structured' || session.status !== 'idle') {
|
|
445
445
|
const error = new Error('Codex must be idle in chat mode before forking');
|
|
446
446
|
error.statusCode = 409;
|
|
447
447
|
throw error;
|
|
448
448
|
}
|
|
449
|
-
const sourceThreadId = String(threadId ||
|
|
449
|
+
const sourceThreadId = String(threadId || session.threadId || '').trim();
|
|
450
450
|
if (!sourceThreadId) {
|
|
451
451
|
const error = new Error('Choose a Codex thread to fork');
|
|
452
452
|
error.statusCode = 400;
|
|
453
453
|
throw error;
|
|
454
454
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
name: `${source.name} Fork`,
|
|
460
|
-
codexOptions: {
|
|
461
|
-
...(source.hasModelOverride && source.model ? { model: source.model } : {}),
|
|
462
|
-
...(source.hasEffortOverride && source.effort ? { effort: source.effort } : {}),
|
|
463
|
-
...(source.permissionMode ? { permissionMode: source.permissionMode } : {}),
|
|
464
|
-
...(source.sandboxMode ? { sandboxMode: source.sandboxMode } : {})
|
|
465
|
-
}
|
|
466
|
-
});
|
|
467
|
-
target.parentSessionId = source.id;
|
|
468
|
-
target.forkedFromThreadId = sourceThreadId;
|
|
469
|
-
try {
|
|
470
|
-
const result = await target.forkFrom(sourceThreadId);
|
|
471
|
-
if (!result) throw new Error('Unable to fork the selected Codex thread');
|
|
472
|
-
source.append({ kind: 'event', level: 'info', text: `Forked a new session: ${target.name}` });
|
|
473
|
-
return target;
|
|
474
|
-
} catch (error) {
|
|
475
|
-
this.kill(target.id);
|
|
476
|
-
throw error;
|
|
477
|
-
}
|
|
455
|
+
const result = await session.forkFrom(sourceThreadId);
|
|
456
|
+
if (!result) throw new Error('Unable to fork the selected Codex thread');
|
|
457
|
+
session.forkedFromThreadId = sourceThreadId;
|
|
458
|
+
return session;
|
|
478
459
|
}
|
|
479
460
|
|
|
480
461
|
listCodexResumeThreads(id) {
|
package/lib/web/index.html
CHANGED
|
@@ -99,9 +99,13 @@
|
|
|
99
99
|
.codex-conversation { width: min(100%, var(--chat-content-max)); margin: 0 auto; }
|
|
100
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
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; }
|
|
102
|
-
.codex-message { max-width: 100%; margin: 0 0 12px;
|
|
103
|
-
.codex-message.user { max-width: 92%; margin-left: auto;
|
|
102
|
+
.codex-message-block { max-width: 100%; margin: 0 0 12px; }
|
|
103
|
+
.codex-message-block.user { max-width: 92%; margin-left: auto; }
|
|
104
|
+
.codex-message { max-width: 100%; margin: 0; color: #f5f5f7; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
|
|
105
|
+
.codex-message.user { padding: 8px 12px; border: 1px solid rgba(0,122,255,.32); border-radius: 12px; background: rgba(0,122,255,.24); }
|
|
104
106
|
.codex-message.event { color: var(--text-dim); text-align: center; font-size: 12px; }
|
|
107
|
+
.codex-message-time { display: block; width: max-content; margin-top: 4px; padding: 0 2px; color: #8e8e93; font-size: 10px; font-weight: 500; line-height: 1; font-variant-numeric: tabular-nums; }
|
|
108
|
+
.codex-message-block.user .codex-message-time { margin-left: auto; }
|
|
105
109
|
.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
110
|
.codex-status-title { margin-bottom: 9px; color: #64d2ff; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
|
|
107
111
|
.codex-status-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
|
|
@@ -127,6 +131,11 @@
|
|
|
127
131
|
.codex-work-group > summary::before { content: '›'; width: 12px; color: #8e8e93; font-size: 17px; transform-origin: center; }
|
|
128
132
|
.codex-work-group[open] > summary::before { transform: rotate(90deg); }
|
|
129
133
|
.codex-work-group-body { padding-left: 2px; }
|
|
134
|
+
.codex-subagent-group { border-left-color: rgba(100,210,255,.24); }
|
|
135
|
+
.codex-subagent-group > summary { color: #8e9aa6; }
|
|
136
|
+
.codex-subagent-group > summary::before { color: #64a9d1; }
|
|
137
|
+
.codex-subagent-message { margin: 7px 0; padding: 7px 9px; border-left: 1px solid rgba(255,255,255,.1); color: #c7c7cc; font-size: 12px; line-height: 1.42; overflow-wrap: anywhere; }
|
|
138
|
+
.codex-subagent-message.task { color: #8e8e93; font-style: italic; }
|
|
130
139
|
.codex-patch-file { border-top: 1px solid rgba(255,255,255,.08); }
|
|
131
140
|
.codex-patch-file:first-child { border-top: 0; }
|
|
132
141
|
.codex-patch-file > summary { display: flex; align-items: center; gap: 8px; padding: 8px 10px; color: #d1d5db; font: 12px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
@@ -327,7 +336,7 @@
|
|
|
327
336
|
.codex-control-page .claude-ctrl-btn { height: 36px; padding-left: 4px; padding-right: 4px; }
|
|
328
337
|
.codex-select-control { height: 36px; }
|
|
329
338
|
#codex-chat-container { padding: 10px 10px calc(22px + env(safe-area-inset-bottom)); }
|
|
330
|
-
.codex-message.user { max-width: 94%; }
|
|
339
|
+
.codex-message-block.user { max-width: 94%; }
|
|
331
340
|
.claude-permission-actions { justify-content: flex-start; }
|
|
332
341
|
}
|
|
333
342
|
@media (max-width: 430px) {
|
|
@@ -451,7 +460,7 @@
|
|
|
451
460
|
<button id="codex-status-btn" class="claude-ctrl-btn" onclick="requestCodexStatus()" title="Show Codex account, limits, and context status">Status</button>
|
|
452
461
|
<button id="codex-abort-btn" class="claude-ctrl-btn danger" onclick="abortCodexSession()" title="Abort current Codex turn">Abort</button>
|
|
453
462
|
<button id="codex-resume-btn" class="claude-ctrl-btn primary" onclick="toggleCodexResumePanel()" title="Resume a Codex thread">Resume</button>
|
|
454
|
-
<button id="codex-fork-btn" class="claude-ctrl-btn primary" onclick="toggleCodexForkPanel()" title="Fork a Codex thread
|
|
463
|
+
<button id="codex-fork-btn" class="claude-ctrl-btn primary" onclick="toggleCodexForkPanel()" title="Fork a Codex thread in this conversation">Fork</button>
|
|
455
464
|
</div>
|
|
456
465
|
<div class="codex-control-page">
|
|
457
466
|
<label class="codex-select-control" title="Sandbox mode">
|
|
@@ -2197,7 +2206,56 @@
|
|
|
2197
2206
|
}).join('');
|
|
2198
2207
|
const label = running ? 'Working' : failed ? 'Work finished with errors' : duration ? `Worked for ${duration}` : 'Worked';
|
|
2199
2208
|
const key = items.map(item => item.id || item.providerId || '').join('-');
|
|
2200
|
-
return `<details class="codex-work-group" data-codex-key="group-${escapeHtml(key)}"
|
|
2209
|
+
return `<details class="codex-work-group" data-codex-key="group-${escapeHtml(key)}"><summary>${label} · ${items.length} ${items.length === 1 ? 'tool' : 'tools'}</summary><div class="codex-work-group-body">${tools}</div></details>`;
|
|
2210
|
+
}
|
|
2211
|
+
function isCodexSubagentItem(item) {
|
|
2212
|
+
return Boolean(item?.threadId && codexState.threadId && item.threadId !== codexState.threadId);
|
|
2213
|
+
}
|
|
2214
|
+
function renderCodexSubagentGroup(threadId, items, permissionById, usedPermissions) {
|
|
2215
|
+
const turnEnds = items.filter(item => item.kind === 'turn-end');
|
|
2216
|
+
const content = items.filter(item => !['reasoning', 'turn-start', 'turn-end'].includes(item.kind));
|
|
2217
|
+
const tools = content.filter(item => item.kind === 'tool');
|
|
2218
|
+
const messages = content.filter(item => item.kind === 'assistant' || item.kind === 'user');
|
|
2219
|
+
const running = tools.some(item => codexToolStatus(item) === 'running')
|
|
2220
|
+
|| items.filter(item => item.kind === 'turn-start').length > turnEnds.length;
|
|
2221
|
+
const startedAt = Math.min(...items.map(item => Number(item.startedAtMs || item.createdAt || Infinity)));
|
|
2222
|
+
const completedAt = Math.max(...turnEnds.map(item => Number(item.createdAt || 0)),
|
|
2223
|
+
...content.map(item => Number(item.completedAtMs || item.updatedAt || 0)));
|
|
2224
|
+
const duration = formatCodexDuration(Number.isFinite(startedAt) && completedAt >= startedAt
|
|
2225
|
+
? completedAt - startedAt : 0);
|
|
2226
|
+
const counts = [];
|
|
2227
|
+
if (tools.length) counts.push(`${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}`);
|
|
2228
|
+
if (messages.length) counts.push(`${messages.length} ${messages.length === 1 ? 'message' : 'messages'}`);
|
|
2229
|
+
const label = running ? 'Subagent working' : duration ? `Subagent worked for ${duration}` : 'Subagent worked';
|
|
2230
|
+
const body = [];
|
|
2231
|
+
for (let i = 0; i < content.length;) {
|
|
2232
|
+
const item = content[i];
|
|
2233
|
+
if (item.kind === 'tool') {
|
|
2234
|
+
const group = [];
|
|
2235
|
+
const turnId = item.turnId;
|
|
2236
|
+
while (i < content.length && content[i].kind === 'tool' && content[i].turnId === turnId) group.push(content[i++]);
|
|
2237
|
+
const turnEnd = turnEnds.find(candidate => candidate.turnId === turnId) || null;
|
|
2238
|
+
body.push(renderCodexToolGroup(group, permissionById, usedPermissions, turnEnd));
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2241
|
+
if (item.kind === 'assistant' || item.kind === 'user') {
|
|
2242
|
+
body.push(`<div class="codex-subagent-message${item.kind === 'user' ? ' task' : ''}">${renderMarkdown(item.text || '')}</div>`);
|
|
2243
|
+
} else if (item.text) {
|
|
2244
|
+
body.push(`<div class="codex-subagent-message">${codexText(item.text)}</div>`);
|
|
2245
|
+
}
|
|
2246
|
+
i += 1;
|
|
2247
|
+
}
|
|
2248
|
+
const suffix = counts.length ? ` · ${counts.join(' · ')}` : '';
|
|
2249
|
+
return `<details class="codex-work-group codex-subagent-group" data-codex-key="subagent-${escapeHtml(threadId)}"><summary>${escapeHtml(label + suffix)}</summary><div class="codex-work-group-body">${body.join('')}</div></details>`;
|
|
2250
|
+
}
|
|
2251
|
+
function renderCodexMessageTime(item, finalOnly = false) {
|
|
2252
|
+
if (!item || (finalOnly && item.streaming)) return '';
|
|
2253
|
+
const timestamp = Number(finalOnly ? (item.completedAtMs || item.updatedAt || item.createdAt) : item.createdAt);
|
|
2254
|
+
if (!Number.isFinite(timestamp) || timestamp <= 0) return '';
|
|
2255
|
+
const date = new Date(timestamp);
|
|
2256
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
2257
|
+
const label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
2258
|
+
return `<time class="codex-message-time" datetime="${escapeHtml(date.toISOString())}" title="${escapeHtml(date.toLocaleString())}">${escapeHtml(label)}</time>`;
|
|
2201
2259
|
}
|
|
2202
2260
|
function syncCodexDom(current, next) {
|
|
2203
2261
|
if (!current || !next) return;
|
|
@@ -2243,29 +2301,45 @@
|
|
|
2243
2301
|
function commitCodexChatRender() {
|
|
2244
2302
|
const container = document.getElementById('codex-chat-container');
|
|
2245
2303
|
if (!container) return;
|
|
2304
|
+
const wasEmpty = !container.firstElementChild;
|
|
2305
|
+
const previousScrollTop = container.scrollTop;
|
|
2306
|
+
const distanceFromBottom = container.scrollHeight - container.clientHeight - previousScrollTop;
|
|
2307
|
+
const shouldStickToBottom = wasEmpty || distanceFromBottom <= 64;
|
|
2246
2308
|
const parts = [];
|
|
2247
2309
|
const permissionById = new Map(codexPendingPermissions.map(item => [String(item.id), item]));
|
|
2248
2310
|
const usedPermissions = new Set();
|
|
2249
2311
|
const turnEndById = new Map(codexMessages.filter(item => item.kind === 'turn-end' && item.turnId)
|
|
2250
2312
|
.map(item => [String(item.turnId), item]));
|
|
2313
|
+
const subagentItems = new Map();
|
|
2314
|
+
for (const item of codexMessages) {
|
|
2315
|
+
if (!isCodexSubagentItem(item)) continue;
|
|
2316
|
+
const threadId = String(item.threadId);
|
|
2317
|
+
if (!subagentItems.has(threadId)) subagentItems.set(threadId, []);
|
|
2318
|
+
subagentItems.get(threadId).push(item);
|
|
2319
|
+
}
|
|
2320
|
+
const renderedSubagents = new Set();
|
|
2251
2321
|
const visible = codexMessages.filter(item => !['reasoning', 'turn-start', 'turn-end'].includes(item.kind));
|
|
2252
2322
|
for (let i = 0; i < visible.length;) {
|
|
2253
2323
|
const item = visible[i];
|
|
2324
|
+
if (isCodexSubagentItem(item)) {
|
|
2325
|
+
const threadId = String(item.threadId);
|
|
2326
|
+
if (!renderedSubagents.has(threadId)) {
|
|
2327
|
+
renderedSubagents.add(threadId);
|
|
2328
|
+
parts.push(renderCodexSubagentGroup(threadId, subagentItems.get(threadId) || [], permissionById, usedPermissions));
|
|
2329
|
+
}
|
|
2330
|
+
i += 1;
|
|
2331
|
+
continue;
|
|
2332
|
+
}
|
|
2254
2333
|
if (item.kind === 'tool') {
|
|
2255
2334
|
const tools = [];
|
|
2256
2335
|
const turnId = item.turnId;
|
|
2257
2336
|
while (i < visible.length && visible[i].kind === 'tool' && visible[i].turnId === turnId) tools.push(visible[i++]);
|
|
2258
|
-
|
|
2337
|
+
parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions,
|
|
2259
2338
|
turnEndById.get(String(turnId || ''))));
|
|
2260
|
-
else {
|
|
2261
|
-
const permission = permissionById.get(String(tools[0].providerId || ''));
|
|
2262
|
-
if (permission) usedPermissions.add(permission.id);
|
|
2263
|
-
parts.push(renderCodexTool(tools[0], permission));
|
|
2264
|
-
}
|
|
2265
2339
|
continue;
|
|
2266
2340
|
}
|
|
2267
|
-
if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant
|
|
2268
|
-
else if (item.kind === 'user') parts.push(`<div class="codex-message user
|
|
2341
|
+
if (item.kind === 'assistant') parts.push(`<div class="codex-message-block assistant" data-codex-key="message-${escapeHtml(item.id || '')}"><div class="codex-message assistant claude-md">${renderMarkdown(item.text || '')}</div>${renderCodexMessageTime(item, true)}</div>`);
|
|
2342
|
+
else if (item.kind === 'user') parts.push(`<div class="codex-message-block user" data-codex-key="message-${escapeHtml(item.id || '')}"><div class="codex-message user claude-md">${renderMarkdown(item.text || '')}</div>${renderCodexMessageTime(item)}</div>`);
|
|
2269
2343
|
else if (item.kind === 'status') parts.push(renderCodexStatus(item));
|
|
2270
2344
|
else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
|
|
2271
2345
|
i += 1;
|
|
@@ -2278,7 +2352,7 @@
|
|
|
2278
2352
|
const current = container.firstElementChild;
|
|
2279
2353
|
if (!current) container.appendChild(next);
|
|
2280
2354
|
else syncCodexDom(current, next);
|
|
2281
|
-
|
|
2355
|
+
container.scrollTop = shouldStickToBottom ? container.scrollHeight : previousScrollTop;
|
|
2282
2356
|
}
|
|
2283
2357
|
|
|
2284
2358
|
function applyCodexState(state = {}) {
|
|
@@ -2307,7 +2381,7 @@
|
|
|
2307
2381
|
const abort = document.getElementById('codex-abort-btn');
|
|
2308
2382
|
if (abort) abort.disabled = !codexState.canAbort;
|
|
2309
2383
|
const fork = document.getElementById('codex-fork-btn');
|
|
2310
|
-
if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle'
|
|
2384
|
+
if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle');
|
|
2311
2385
|
const terminal = document.getElementById('codex-terminal-switch');
|
|
2312
2386
|
if (terminal) { terminal.textContent = codexState.presentation === 'terminal' ? 'CHAT' : 'TERM'; terminal.disabled = codexState.presentation === 'structured' && !codexState.canSwitchToTerminal; terminal.title = codexState.presentation === 'terminal' ? 'Return to Codex chat' : 'Switch to Codex terminal'; }
|
|
2313
2387
|
renderCodexStateBar();
|
|
@@ -2393,7 +2467,7 @@
|
|
|
2393
2467
|
await loadCodexThreadPanel(panel, 'resume');
|
|
2394
2468
|
}
|
|
2395
2469
|
async function toggleCodexForkPanel() {
|
|
2396
|
-
if (!(codexState.presentation === 'structured' && codexState.status === 'idle'
|
|
2470
|
+
if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
|
|
2397
2471
|
codexForkPanelOpen = !codexForkPanelOpen;
|
|
2398
2472
|
codexModelPanelOpen = false;
|
|
2399
2473
|
codexResumePanelOpen = false;
|
|
@@ -2429,7 +2503,7 @@
|
|
|
2429
2503
|
}
|
|
2430
2504
|
async function selectCodexForkThread(threadId) {
|
|
2431
2505
|
const panel = document.getElementById('codex-fork-panel');
|
|
2432
|
-
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Forking
|
|
2506
|
+
panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Forking and switching this conversation...</div>';
|
|
2433
2507
|
updateTerminalControlsHeight();
|
|
2434
2508
|
const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-fork`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 60000);
|
|
2435
2509
|
const data = await res.json();
|
|
@@ -2442,7 +2516,6 @@
|
|
|
2442
2516
|
panel.classList.remove('active');
|
|
2443
2517
|
panel.innerHTML = '';
|
|
2444
2518
|
updateTerminalControlsHeight();
|
|
2445
|
-
refreshSessionsNow();
|
|
2446
2519
|
}
|
|
2447
2520
|
async function toggleCodexPresentation() {
|
|
2448
2521
|
const presentation = codexState.presentation === 'terminal' ? 'structured' : 'terminal';
|