glad-web 1.0.25 → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,6 +63,22 @@ function textFromInputItems(content) {
63
63
  .join('\n');
64
64
  }
65
65
 
66
+ function recentUserQuestions(thread, limit = 2) {
67
+ const questions = [];
68
+ const turns = Array.isArray(thread?.turns) ? thread.turns : [];
69
+ for (let turnIndex = turns.length - 1; turnIndex >= 0 && questions.length < limit; turnIndex -= 1) {
70
+ const items = Array.isArray(turns[turnIndex]?.items) ? turns[turnIndex].items : [];
71
+ for (let itemIndex = items.length - 1; itemIndex >= 0 && questions.length < limit; itemIndex -= 1) {
72
+ const item = items[itemIndex];
73
+ if (item?.type !== 'userMessage') continue;
74
+ const text = (textFromInputItems(item.content) || item.text || '').trim();
75
+ if (text) questions.push(text);
76
+ }
77
+ }
78
+ while (questions.length < limit) questions.push('');
79
+ return questions;
80
+ }
81
+
66
82
  function toolDetails(raw) {
67
83
  if (raw.type === 'commandExecution') {
68
84
  return {
@@ -98,13 +114,22 @@ function toolDetails(raw) {
98
114
  };
99
115
  }
100
116
  if (raw.type === 'collabAgentToolCall') {
117
+ const receiverThreadIds = Array.isArray(raw.receiverThreadIds) ? raw.receiverThreadIds.filter(Boolean) : [];
118
+ const input = raw.arguments || raw.input || {
119
+ ...(raw.prompt ? { prompt: raw.prompt } : {}),
120
+ ...(receiverThreadIds.length ? { receiverThreadIds } : {}),
121
+ ...(raw.agentsStates && Object.keys(raw.agentsStates).length ? { agentsStates: raw.agentsStates } : {})
122
+ };
101
123
  return {
102
124
  name: 'Agent',
103
125
  title: raw.tool || raw.action || 'Subagent',
104
- input: raw.arguments || raw.input || raw,
105
- result: raw.error != null ? String(raw.error) : safeJson(raw.result || ''),
126
+ tool: raw.tool || raw.action || 'subagent',
127
+ input,
128
+ result: raw.error != null ? String(raw.error) : raw.result == null ? '' : safeJson(raw.result),
106
129
  error: raw.error != null ? String(raw.error) : null,
107
- subagentId: raw.receiverThreadId || raw.agentId || raw.id || null
130
+ subagentId: raw.receiverThreadId || receiverThreadIds[0] || raw.agentId || null,
131
+ subagentIds: receiverThreadIds,
132
+ agentsStates: raw.agentsStates || {}
108
133
  };
109
134
  }
110
135
  return {
@@ -135,6 +160,7 @@ class CodexStructuredSession extends EventEmitter {
135
160
  this.threadId = options.resume || null;
136
161
  this.currentTurnId = null;
137
162
  this.currentTurnStartedAt = null;
163
+ this.threadTurns = new Map();
138
164
  this.tokenUsage = null;
139
165
  this.permissionMode = normalizePermissionMode(options.permissionMode);
140
166
  this.sandboxMode = normalizeSandboxMode(options.sandboxMode);
@@ -186,6 +212,8 @@ class CodexStructuredSession extends EventEmitter {
186
212
  }
187
213
 
188
214
  getControlState() {
215
+ const activeSubagentCount = Array.from(this.threadTurns.entries())
216
+ .filter(([threadId, turn]) => threadId !== this.threadId && turn?.status === 'running').length;
189
217
  return { permissionMode: this.permissionMode || 'default', sandboxMode: this.sandboxMode || 'default',
190
218
  effectivePermissionMode: this.effectivePermissionMode, effectiveSandboxMode: this.effectiveSandboxMode,
191
219
  model: this.model, effort: this.effort,
@@ -193,7 +221,7 @@ class CodexStructuredSession extends EventEmitter {
193
221
  canAbort: this.presentation === 'structured' && this.status !== 'idle',
194
222
  canSwitchToTerminal: this.presentation === 'structured' && this.status === 'idle' && Boolean(this.threadId),
195
223
  canSwitchToStructured: this.presentation === 'terminal',
196
- pendingPermissionCount: this.pendingPermissions.size, models: this.models };
224
+ pendingPermissionCount: this.pendingPermissions.size, activeSubagentCount, models: this.models };
197
225
  }
198
226
 
199
227
  getHistory() {
@@ -225,6 +253,7 @@ class CodexStructuredSession extends EventEmitter {
225
253
  }
226
254
  emitEvent(event) { this.emit('event', event); }
227
255
  setStatus(status) { if (this.status !== status) { this.status = status; this.emitEvent({ type: 'state', state: this.getControlState() }); } }
256
+ emitControlState() { this.emitEvent({ type: 'state', state: this.getControlState() }); }
228
257
  recordPermission(request, status, decision) {
229
258
  const completed = { ...request, status, decision };
230
259
  this.completedPermissions = [...this.completedPermissions.filter(item => item.id !== request.id), completed].slice(-50);
@@ -349,44 +378,75 @@ class CodexStructuredSession extends EventEmitter {
349
378
  return;
350
379
  }
351
380
  if (method === 'turn/started') {
352
- this.currentTurnId = params.turn?.id || params.turnId || this.currentTurnId;
353
- this.currentTurnStartedAt = Date.now();
354
- this.append({ kind: 'turn-start', turnId: this.currentTurnId });
355
- this.setStatus('running');
381
+ const threadId = params.threadId || this.threadId;
382
+ const turnId = params.turn?.id || params.turnId || null;
383
+ const startedAt = Number(params.turn?.startedAt || 0);
384
+ const startedAtMs = startedAt > 0 && startedAt < 100000000000 ? startedAt * 1000 : startedAt || Date.now();
385
+ if (threadId && turnId) this.threadTurns.set(threadId, { turnId, startedAt: startedAtMs, status: 'running' });
386
+ if (!threadId || threadId === this.threadId) {
387
+ this.currentTurnId = turnId || this.currentTurnId;
388
+ this.currentTurnStartedAt = startedAtMs;
389
+ this.setStatus('running');
390
+ } else {
391
+ this.emitControlState();
392
+ }
393
+ this.append({ kind: 'turn-start', threadId, turnId, createdAt: startedAtMs });
356
394
  return;
357
395
  }
358
396
  if (method === 'turn/completed') {
359
- const completedTurnId = params.turn?.id || params.turnId || this.currentTurnId;
397
+ const threadId = params.threadId || this.threadId;
398
+ const trackedTurn = threadId ? this.threadTurns.get(threadId) : null;
399
+ const completedTurnId = params.turn?.id || params.turnId || trackedTurn?.turnId || this.currentTurnId;
360
400
  const turnStatus = params.turn?.status === 'failed' || params.turn?.error ? 'failed'
361
401
  : params.turn?.status === 'interrupted' ? 'cancelled' : 'completed';
362
- this.append({ kind: 'turn-end', turnId: completedTurnId, status: turnStatus,
363
- durationMs: this.currentTurnStartedAt ? Date.now() - this.currentTurnStartedAt : null });
402
+ const completedAt = Number(params.turn?.completedAt || 0);
403
+ const completedAtMs = completedAt > 0 && completedAt < 100000000000 ? completedAt * 1000 : completedAt || Date.now();
404
+ const startedAtMs = trackedTurn?.startedAt || ((!threadId || threadId === this.threadId) ? this.currentTurnStartedAt : null);
405
+ const durationMs = Number(params.turn?.durationMs || 0)
406
+ || (startedAtMs ? Math.max(0, completedAtMs - startedAtMs) : null);
407
+ this.append({ kind: 'turn-end', threadId, turnId: completedTurnId, status: turnStatus,
408
+ durationMs, createdAt: completedAtMs });
409
+ const observedNow = Date.now();
410
+ const observedCompletedAtMs = Math.abs(observedNow - completedAtMs) < 5000
411
+ ? Math.max(completedAtMs, observedNow) : completedAtMs;
364
412
  for (const item of this.messages.filter(message => message.kind === 'tool'
365
413
  && message.turnId === completedTurnId && ['running', 'inProgress'].includes(message.toolStatus))) {
366
- this.patch(item.id, { toolStatus: turnStatus === 'failed' ? 'failed' : 'completed' });
367
- }
368
- for (const pending of this.pendingPermissions.values()) {
369
- this.recordPermission(pending.public, 'denied', 'abort');
414
+ const toolDurationMs = Number(item.durationMs || 0)
415
+ || (item.startedAtMs || item.createdAt ? Math.max(1, observedCompletedAtMs - Number(item.startedAtMs || item.createdAt)) : null);
416
+ const toolStatus = turnStatus === 'failed' ? 'failed' : turnStatus === 'cancelled' ? 'cancelled' : 'completed';
417
+ this.patch(item.id, { toolStatus,
418
+ completedAtMs: observedCompletedAtMs, ...(toolDurationMs != null ? { durationMs: toolDurationMs } : {}) });
370
419
  }
371
- this.currentTurnId = null;
372
- this.currentTurnStartedAt = null;
373
- this.pendingPermissions.clear();
374
- if (params.turn?.status === 'failed' || params.turn?.error) {
375
- this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
420
+ if (threadId) this.threadTurns.delete(threadId);
421
+ if (!threadId || threadId === this.threadId) {
422
+ for (const pending of this.pendingPermissions.values()) {
423
+ this.recordPermission(pending.public, 'denied', 'abort');
424
+ }
425
+ this.currentTurnId = null;
426
+ this.currentTurnStartedAt = null;
427
+ this.pendingPermissions.clear();
428
+ if (params.turn?.status === 'failed' || params.turn?.error) {
429
+ this.append({ kind: 'event', level: 'error', text: params.turn?.error?.message || 'Codex turn failed.' });
430
+ }
431
+ this.setStatus('idle');
432
+ this.hasUnreadCompletion = true;
433
+ } else {
434
+ this.emitControlState();
376
435
  }
377
- this.setStatus('idle');
378
- this.hasUnreadCompletion = true;
379
436
  return;
380
437
  }
381
438
  if (method === 'thread/started' || method === 'thread/resumed') {
382
439
  const threadId = params.thread?.id || params.threadId;
383
- if (threadId) { this.threadId = threadId; this.emitEvent({ type: 'state', state: this.getControlState() }); }
440
+ if (threadId && !this.threadId) { this.threadId = threadId; this.emitControlState(); }
384
441
  return;
385
442
  }
386
443
  if (method === 'thread/status/changed') {
444
+ const threadId = params.threadId || this.threadId;
387
445
  const status = params.status?.type || params.status;
388
- if (status === 'idle') this.setStatus('idle');
389
- if (status === 'active') this.setStatus('running');
446
+ if (!threadId || threadId === this.threadId) {
447
+ if (status === 'idle' && !this.currentTurnId) this.setStatus('idle');
448
+ if (status === 'active') this.setStatus('running');
449
+ }
390
450
  return;
391
451
  }
392
452
  if (method === 'thread/settings/updated') {
@@ -430,11 +490,16 @@ class CodexStructuredSession extends EventEmitter {
430
490
  }
431
491
  if (method.startsWith('item/')) {
432
492
  const inferredStatus = method === 'item/completed' ? 'completed' : method === 'item/started' ? 'running' : null;
433
- this.applyProviderItem(params.item || params, inferredStatus);
493
+ this.applyProviderItem(params.item || params, inferredStatus, {
494
+ threadId: params.threadId || null,
495
+ turnId: params.turnId || null,
496
+ startedAtMs: Number(params.startedAtMs || 0) || null,
497
+ completedAtMs: Number(params.completedAtMs || 0) || null
498
+ });
434
499
  }
435
500
  }
436
501
 
437
- applyProviderItem(raw, inferredStatus = null) {
502
+ applyProviderItem(raw, inferredStatus = null, context = {}) {
438
503
  if (!raw || typeof raw !== 'object') return;
439
504
  const providerId = String(raw.id || '');
440
505
  const existing = providerId && this.messages.find(item => item.providerId === providerId);
@@ -443,10 +508,25 @@ class CodexStructuredSession extends EventEmitter {
443
508
  if (!kind) return;
444
509
  const text = kind === 'user' ? textFromInputItems(raw.content) : kind === 'assistant' ? String(raw.text || '')
445
510
  : 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;
448
- const patch = kind === 'tool' ? { ...toolDetails(raw), turnId: raw.turnId || this.currentTurnId,
449
- toolStatus: inferredToolStatus || raw.status || 'running' } : { text, turnId: raw.turnId || this.currentTurnId, streaming: false };
511
+ const inferredToolStatus = existing?.toolStatus === 'cancelled' ? 'cancelled'
512
+ : inferredStatus === 'completed' && ['failed', 'declined'].includes(raw.status) ? raw.status : inferredStatus;
513
+ const threadId = raw.threadId || context.threadId || null;
514
+ const trackedTurnId = threadId ? this.threadTurns.get(threadId)?.turnId : null;
515
+ const turnId = raw.turnId || context.turnId || trackedTurnId || this.currentTurnId;
516
+ const existingStartedAtMs = existing?.startedAtMs || existing?.createdAt || null;
517
+ const startedAtMs = context.startedAtMs || raw.startedAtMs || existingStartedAtMs;
518
+ const completedAtMs = context.completedAtMs || raw.completedAtMs || null;
519
+ const durationMs = Number(raw.durationMs || 0)
520
+ || (completedAtMs && startedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null)
521
+ || Number(existing?.durationMs || 0) || null;
522
+ const timing = {
523
+ ...(startedAtMs ? { startedAtMs } : {}),
524
+ ...(completedAtMs ? { completedAtMs } : {}),
525
+ ...(durationMs != null ? { durationMs } : {})
526
+ };
527
+ const patch = kind === 'tool' ? { ...toolDetails(raw), threadId, turnId,
528
+ ...timing, toolStatus: inferredToolStatus || raw.status || 'running' }
529
+ : { text, threadId, turnId, streaming: false };
450
530
  if (existing) {
451
531
  this.patch(existing.id, patch);
452
532
  } else if (kind === 'user') {
@@ -454,7 +534,7 @@ class CodexStructuredSession extends EventEmitter {
454
534
  if (local) this.patch(local.id, { providerId, ...patch });
455
535
  else this.append({ kind, providerId, ...patch });
456
536
  } else {
457
- this.append({ kind, providerId, ...patch });
537
+ this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
458
538
  }
459
539
  }
460
540
 
@@ -501,14 +581,28 @@ class CodexStructuredSession extends EventEmitter {
501
581
  archived: false,
502
582
  cwd: this.workingDir
503
583
  });
504
- return (result?.data || []).filter(item => !item.parentThreadId).map(item => ({
505
- id: item.id,
506
- sessionId: item.sessionId || item.id,
507
- preview: item.preview || item.name || '',
508
- updatedAt: Number(item.updatedAt || item.createdAt || 0) * 1000,
509
- cwd: item.cwd || '',
510
- current: item.id === this.threadId
511
- }));
584
+ const threads = (result?.data || []).filter(item => !item.parentThreadId);
585
+ const items = [];
586
+ for (const item of threads) {
587
+ let questions = [];
588
+ try {
589
+ const history = await this.request('thread/read', { threadId: item.id, includeTurns: true });
590
+ questions = recentUserQuestions(history?.thread);
591
+ } catch (error) {
592
+ this.logger.debugInfo?.(`[codex-app-server] unable to read resume preview for ${item.id}: ${error.message}`);
593
+ }
594
+ if (!questions[0]) questions[0] = item.preview || '';
595
+ if (questions.length < 2) questions.push('');
596
+ items.push({
597
+ id: item.id,
598
+ sessionId: item.sessionId || item.id,
599
+ questions: questions.slice(0, 2),
600
+ updatedAt: Number(item.updatedAt || item.createdAt || 0) * 1000,
601
+ cwd: item.cwd || '',
602
+ current: item.id === this.threadId
603
+ });
604
+ }
605
+ return items;
512
606
  }
513
607
 
514
608
  contextStatus() {
@@ -686,9 +780,16 @@ class CodexStructuredSession extends EventEmitter {
686
780
  this.recordPermission(pending.public, 'denied', 'abort');
687
781
  }
688
782
  this.pendingPermissions.clear();
689
- if (this.threadId && this.currentTurnId) {
690
- this.request('turn/interrupt', { threadId: this.threadId, turnId: this.currentTurnId }).catch(error => {
691
- this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed: ${error.message}`);
783
+ const targets = Array.from(this.threadTurns.entries())
784
+ .filter(([, turn]) => turn?.turnId && turn.status === 'running')
785
+ .map(([threadId, turn]) => ({ threadId, turnId: turn.turnId }));
786
+ if (this.threadId && this.currentTurnId
787
+ && !targets.some(target => target.threadId === this.threadId && target.turnId === this.currentTurnId)) {
788
+ targets.push({ threadId: this.threadId, turnId: this.currentTurnId });
789
+ }
790
+ for (const target of targets) {
791
+ this.request('turn/interrupt', target).catch(error => {
792
+ this.logger.debugInfo?.(`[codex-app-server] turn/interrupt failed for ${target.threadId}/${target.turnId}: ${error.message}`);
692
793
  });
693
794
  }
694
795
  this.append({ kind: 'event', level: 'info', text: reason });
@@ -116,6 +116,7 @@
116
116
  .codex-tool-title { flex: 0 1 auto; min-width: 0; color: #f5f5f7; font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
117
117
  .codex-tool-command { flex: 1; min-width: 0; color: #a9a9b0; font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
118
118
  .codex-tool-state { flex: 0 0 auto; color: #8e8e93; font-size: 11px; }
119
+ .codex-tool-duration { flex: 0 0 auto; color: #6f6f76; font-size: 10px; font-variant-numeric: tabular-nums; }
119
120
  .codex-tool-state.running::before { content: ''; display: inline-block; width: 9px; height: 9px; margin-right: 5px; border: 1.5px solid #8e8e93; border-top-color: transparent; border-radius: 50%; animation: codex-spin .8s linear infinite; vertical-align: -1px; }
120
121
  .codex-tool.error { border-color: rgba(255,59,48,.38); background: rgba(255,59,48,.07); }
121
122
  .codex-tool-body { border-top: 1px solid rgba(255,255,255,.08); padding: 9px 10px; }
@@ -257,6 +258,7 @@
257
258
  .claude-resume-item:last-child { border-bottom: 0; }
258
259
  .claude-resume-item:active { background: rgba(255,255,255,0.08); }
259
260
  .claude-resume-title { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; font-weight: 800; }
261
+ .codex-resume-question-secondary { min-height: 1.35em; color: var(--text-dim); font-size: 11px; margin-top: 3px; overflow-wrap: anywhere; }
260
262
  .claude-resume-meta { color: var(--text-dim); font-size: 11px; margin-top: 3px; overflow-wrap: anywhere; }
261
263
  #terminal .xterm, #terminal .xterm-viewport, #terminal .xterm-screen, #terminal .xterm-rows, #terminal .xterm-rows span { user-select: text !important; -webkit-user-select: text !important; -webkit-touch-callout: default; touch-action: pan-y; }
262
264
  #terminal .xterm-viewport { overflow-anchor: none; }
@@ -658,6 +660,7 @@
658
660
  let codexModelPanelOpen = false;
659
661
  let codexModelCandidate = null;
660
662
  let codexResumePanelOpen = false;
663
+ let codexRenderFrame = null;
661
664
  const modifiers = { ctrl: false };
662
665
 
663
666
  function log(msg) {
@@ -2070,6 +2073,16 @@
2070
2073
  if (status === 'completed') return item.exitCode && item.exitCode !== 0 ? 'failed' : 'completed';
2071
2074
  return status;
2072
2075
  }
2076
+ function formatCodexDuration(durationMs) {
2077
+ const value = Number(durationMs || 0);
2078
+ if (!(value > 0)) return '';
2079
+ if (value < 1000) return `${Math.round(value)}ms`;
2080
+ const seconds = value / 1000;
2081
+ if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`;
2082
+ if (seconds < 60) return `${Math.round(seconds)}s`;
2083
+ const minutes = Math.floor(seconds / 60);
2084
+ return `${minutes}m ${Math.round(seconds % 60)}s`;
2085
+ }
2073
2086
  function renderCodexDiff(diff) {
2074
2087
  return `<div class="codex-diff">${String(diff || '').split('\n').map(line => {
2075
2088
  const type = line.startsWith('+++') || line.startsWith('---') ? 'hunk' : line.startsWith('+') ? 'add' : line.startsWith('-') ? 'del' : line.startsWith('@@') ? 'hunk' : '';
@@ -2153,27 +2166,72 @@
2153
2166
  const command = item.name === 'CodexBash' ? item.command : item.title || item.tool || '';
2154
2167
  const icon = item.name === 'CodexBash' ? '>_' : item.name === 'McpTool' ? 'MCP' : item.name === 'Agent' ? 'A' : '•';
2155
2168
  const title = item.name === 'CodexBash' ? 'Command' : item.title || item.name || 'Tool';
2156
- const result = item.result || (item.name === 'McpTool' || item.name === 'Agent' ? codexJson(item.input) : '');
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>`;
2169
+ const hasInput = item.input && typeof item.input === 'object' && Object.keys(item.input).length > 0;
2170
+ const result = item.result || ((item.name === 'McpTool' || item.name === 'Agent') && hasInput ? codexJson(item.input) : '');
2171
+ const duration = status === 'running' ? '' : formatCodexDuration(item.durationMs);
2172
+ return `<details class="codex-tool${isError ? ' error' : ''}" data-codex-key="tool-${escapeHtml(item.id || item.providerId || '')}"><summary class="codex-tool-header"><span class="codex-tool-icon">${escapeHtml(icon)}</span><span class="codex-tool-title">${escapeHtml(title)}</span>${command && command !== title ? `<span class="codex-tool-command">${escapeHtml(command)}</span>` : '<span class="codex-tool-command"></span>'}${duration ? `<span class="codex-tool-duration">${escapeHtml(duration)}</span>` : ''}<span class="codex-tool-state${runningClass}">${escapeHtml(status === 'completed' ? '' : status)}</span></summary>${result ? `<div class="codex-tool-body"><pre class="codex-tool-code">${escapeHtml(result)}</pre></div>` : ''}${permission ? renderCodexPermission(permission, true) : ''}</details>`;
2158
2173
  }
2159
2174
  function renderCodexToolGroup(items, permissionById, usedPermissions, turnEnd = null) {
2160
2175
  const running = items.some(item => codexToolStatus(item) === 'running');
2161
2176
  const failed = items.some(item => codexToolStatus(item) === 'failed' || item.error);
2162
- const startedAt = Math.min(...items.map(item => Number(item.createdAt || Date.now())));
2177
+ const startedAt = Math.min(...items.map(item => Number(item.startedAtMs || item.createdAt || Date.now())));
2178
+ const itemCompletedAt = Math.max(...items.map(item => Number(item.completedAtMs || 0)));
2163
2179
  const storedDuration = Number(turnEnd?.durationMs || 0);
2164
2180
  const completedAt = Number(turnEnd?.createdAt || 0);
2165
- const durationMs = storedDuration > 0 ? storedDuration
2181
+ const durationMs = itemCompletedAt >= startedAt ? itemCompletedAt - startedAt : storedDuration > 0 ? storedDuration
2166
2182
  : (!running && completedAt >= startedAt ? completedAt - startedAt : 0);
2167
- const seconds = durationMs > 0 ? Math.max(1, Math.round(durationMs / 1000)) : null;
2183
+ const duration = formatCodexDuration(durationMs);
2168
2184
  const tools = items.map(item => {
2169
2185
  const permission = permissionById.get(String(item.providerId || ''));
2170
2186
  if (permission) usedPermissions.add(permission.id);
2171
2187
  return renderCodexTool(item, permission);
2172
2188
  }).join('');
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>`;
2189
+ const label = running ? 'Working' : failed ? 'Work finished with errors' : duration ? `Worked for ${duration}` : 'Worked';
2190
+ const key = items.map(item => item.id || item.providerId || '').join('-');
2191
+ return `<details class="codex-work-group" data-codex-key="group-${escapeHtml(key)}"${running ? ' open' : ''}><summary>${label} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
2192
+ }
2193
+ function syncCodexDom(current, next) {
2194
+ if (!current || !next) return;
2195
+ if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) {
2196
+ current.replaceWith(next.cloneNode(true));
2197
+ return;
2198
+ }
2199
+ if (current.nodeType === Node.TEXT_NODE) {
2200
+ if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
2201
+ return;
2202
+ }
2203
+ const currentKey = current.getAttribute?.('data-codex-key');
2204
+ const nextKey = next.getAttribute?.('data-codex-key');
2205
+ if (currentKey && nextKey && currentKey !== nextKey) {
2206
+ current.replaceWith(next.cloneNode(true));
2207
+ return;
2208
+ }
2209
+ const preserveOpen = current.tagName === 'DETAILS' && currentKey === nextKey;
2210
+ const wasOpen = preserveOpen ? current.open : false;
2211
+ for (const attribute of Array.from(current.attributes || [])) {
2212
+ if (!next.hasAttribute(attribute.name) && !(preserveOpen && attribute.name === 'open')) current.removeAttribute(attribute.name);
2213
+ }
2214
+ for (const attribute of Array.from(next.attributes || [])) {
2215
+ if (!(preserveOpen && attribute.name === 'open') && current.getAttribute(attribute.name) !== attribute.value) {
2216
+ current.setAttribute(attribute.name, attribute.value);
2217
+ }
2218
+ }
2219
+ if (preserveOpen) current.open = wasOpen;
2220
+ const currentChildren = Array.from(current.childNodes);
2221
+ const nextChildren = Array.from(next.childNodes);
2222
+ const shared = Math.min(currentChildren.length, nextChildren.length);
2223
+ for (let i = 0; i < shared; i++) syncCodexDom(currentChildren[i], nextChildren[i]);
2224
+ for (let i = current.childNodes.length - 1; i >= nextChildren.length; i--) current.childNodes[i].remove();
2225
+ for (let i = shared; i < nextChildren.length; i++) current.appendChild(nextChildren[i].cloneNode(true));
2175
2226
  }
2176
2227
  function renderCodexChat() {
2228
+ if (codexRenderFrame != null) return;
2229
+ codexRenderFrame = requestAnimationFrame(() => {
2230
+ codexRenderFrame = null;
2231
+ commitCodexChatRender();
2232
+ });
2233
+ }
2234
+ function commitCodexChatRender() {
2177
2235
  const container = document.getElementById('codex-chat-container');
2178
2236
  if (!container) return;
2179
2237
  const parts = [];
@@ -2197,16 +2255,20 @@
2197
2255
  }
2198
2256
  continue;
2199
2257
  }
2200
- if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant claude-md">${renderMarkdown(item.text || '')}</div>`);
2201
- else if (item.kind === 'user') parts.push(`<div class="codex-message user claude-md">${renderMarkdown(item.text || '')}</div>`);
2258
+ if (item.kind === 'assistant') parts.push(`<div class="codex-message assistant claude-md" data-codex-key="message-${escapeHtml(item.id || '')}">${renderMarkdown(item.text || '')}</div>`);
2259
+ else if (item.kind === 'user') parts.push(`<div class="codex-message user claude-md" data-codex-key="message-${escapeHtml(item.id || '')}">${renderMarkdown(item.text || '')}</div>`);
2202
2260
  else if (item.kind === 'status') parts.push(renderCodexStatus(item));
2203
2261
  else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
2204
2262
  i += 1;
2205
2263
  }
2206
2264
  for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
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>`;
2265
+ const working = `<div class="codex-working-indicator" role="status" aria-label="Codex is working" title="Codex is working"${codexState.status === 'running' ? '' : ' style="display:none"'}></div>`;
2266
+ const template = document.createElement('template');
2267
+ template.innerHTML = `<div class="codex-conversation">${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
2268
+ const next = template.content.firstElementChild;
2269
+ const current = container.firstElementChild;
2270
+ if (!current) container.appendChild(next);
2271
+ else syncCodexDom(current, next);
2210
2272
  requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
2211
2273
  }
2212
2274
 
@@ -2247,8 +2309,10 @@
2247
2309
  const el = document.getElementById('codex-state-bar');
2248
2310
  if (!el) return;
2249
2311
  const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
2312
+ const subagents = Number(codexState.activeSubagentCount || 0) || 0;
2250
2313
  const parts = [];
2251
2314
  if (pending || codexState.status === 'waiting_approval') parts.push(`<span class="claude-state-pill warn">${pending || 1} approval${pending === 1 ? '' : 's'}</span>`);
2315
+ if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
2252
2316
  el.innerHTML = parts.join('');
2253
2317
  el.style.display = parts.length ? 'flex' : 'none';
2254
2318
  }
@@ -2317,7 +2381,10 @@
2317
2381
  const data = await res.json();
2318
2382
  if (!res.ok || !data.success) throw new Error(data.error || 'Failed to load Codex sessions');
2319
2383
  const items = data.items || [];
2320
- panel.innerHTML = items.length ? items.map(item => `<button class="claude-resume-item" onclick="selectCodexResumeThread(decodePathValue('${encodePathValue(item.id)}'))"><div class="claude-resume-title"><span>${escapeHtml((item.preview || 'Codex session').slice(0, 48))}${item.current ? ' · current' : ''}</span><span>${escapeHtml(item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '')}</span></div><div class="claude-resume-meta">${escapeHtml(item.id.slice(0, 12))}</div></button>`).join('') : '<div class="claude-resume-meta" style="padding:12px;">No Codex sessions found for this folder.</div>';
2384
+ panel.innerHTML = items.length ? items.map(item => {
2385
+ const questions = Array.isArray(item.questions) ? item.questions : [];
2386
+ return `<button class="claude-resume-item" onclick="selectCodexResumeThread(decodePathValue('${encodePathValue(item.id)}'))"><div class="claude-resume-title"><span>${escapeHtml((questions[0] || 'Codex session').slice(0, 120))}${item.current ? ' · current' : ''}</span><span>${escapeHtml(item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '')}</span></div><div class="codex-resume-question-secondary">${escapeHtml((questions[1] || '').slice(0, 120))}</div></button>`;
2387
+ }).join('') : '<div class="claude-resume-meta" style="padding:12px;">No Codex sessions found for this folder.</div>';
2321
2388
  } catch (e) { panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(e.message)}</div>`; }
2322
2389
  }
2323
2390
  async function selectCodexResumeThread(threadId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "description": "Glad transforms terminal-based AI coding tools into a polished, mobile-friendly local Web interface.",
5
5
  "main": "index.js",
6
6
  "bin": {