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.
@@ -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
- if (target) this.patch(target.id, { text: String(target.text || '') + String(params.delta || '') });
479
- else this.append({ kind: 'reasoning', providerId, text: String(params.delta || ''), streaming: true });
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 (target) this.patch(target.id, { text: (target.text || '') + delta });
488
- else this.append({ kind, providerId: itemId, text: delta, streaming: true });
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 || existingStartedAtMs;
518
- const completedAtMs = context.completedAtMs || raw.completedAtMs || null;
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
- await this.ensureProcess();
706
- if (!this.threadId) {
707
- const params = { cwd: this.workingDir };
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
- if (this.sandboxMode) params.sandbox = this.sandboxMode;
711
- const started = await this.request('thread/start', params);
712
- this.threadId = started.thread?.id;
713
- this.model = started.model || this.model;
714
- this.effort = started.reasoningEffort || this.effort;
715
- this.effectivePermissionMode = started.approvalPolicy || this.effectivePermissionMode;
716
- this.effectiveSandboxMode = sandboxModeFromPolicy(started.sandbox) || this.effectiveSandboxMode;
717
- this.emitEvent({ type: 'state', state: this.getControlState() });
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.currentTurnId = null;
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 || []) this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed');
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 source = this.get(id);
443
- if (!source || source.kind !== 'codex-structured') return null;
444
- if (source.presentation !== 'structured' || source.status !== 'idle') {
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 || source.threadId || '').trim();
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
- const target = this.createCodexStructuredSession({
457
- tool: source.tool,
458
- workingDirectory: this.getSessionWorkingDirectory(source),
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) {
@@ -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; color: #f5f5f7; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
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); }
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 into a new Glad session">Fork</button>
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)}"${running ? ' open' : ''}><summary>${label} · ${items.length} tools</summary><div class="codex-work-group-body">${tools}</div></details>`;
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
- if (tools.length > 1) parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions,
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 claude-md" data-codex-key="message-${escapeHtml(item.id || '')}">${renderMarkdown(item.text || '')}</div>`);
2268
- 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>`);
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
- requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
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' && codexState.threadId);
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' && codexState.threadId)) return;
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 into a new session...</div>';
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
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": {