glad-web 1.0.27 → 1.0.28

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
 
@@ -247,7 +253,7 @@ class CodexStructuredSession extends EventEmitter {
247
253
  patch(id, patch) {
248
254
  const item = this.messages.find(message => message.id === id);
249
255
  if (!item) return null;
250
- Object.assign(item, patch);
256
+ Object.assign(item, { updatedAt: Date.now() }, patch);
251
257
  this.emitEvent({ type: 'message-updated', message: item });
252
258
  return item;
253
259
  }
@@ -514,8 +520,10 @@ class CodexStructuredSession extends EventEmitter {
514
520
  const trackedTurnId = threadId ? this.threadTurns.get(threadId)?.turnId : null;
515
521
  const turnId = raw.turnId || context.turnId || trackedTurnId || this.currentTurnId;
516
522
  const existingStartedAtMs = existing?.startedAtMs || existing?.createdAt || null;
517
- const startedAtMs = context.startedAtMs || raw.startedAtMs || existingStartedAtMs;
518
- const completedAtMs = context.completedAtMs || raw.completedAtMs || null;
523
+ const startedAtMs = Number(context.startedAtMs || raw.startedAtMs || 0)
524
+ || toTimestampMs(raw.startedAt || raw.createdAt) || existingStartedAtMs;
525
+ const completedAtMs = Number(context.completedAtMs || raw.completedAtMs || 0)
526
+ || toTimestampMs(raw.completedAt || raw.updatedAt);
519
527
  const durationMs = Number(raw.durationMs || 0)
520
528
  || (completedAtMs && startedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null)
521
529
  || Number(existing?.durationMs || 0) || null;
@@ -526,13 +534,13 @@ class CodexStructuredSession extends EventEmitter {
526
534
  };
527
535
  const patch = kind === 'tool' ? { ...toolDetails(raw), threadId, turnId,
528
536
  ...timing, toolStatus: inferredToolStatus || raw.status || 'running' }
529
- : { text, threadId, turnId, streaming: false };
537
+ : { text, threadId, turnId, streaming: false, ...(completedAtMs ? { completedAtMs } : {}) };
530
538
  if (existing) {
531
539
  this.patch(existing.id, patch);
532
540
  } else if (kind === 'user') {
533
541
  const local = [...this.messages].reverse().find(item => item.kind === 'user' && !item.providerId && item.text === text);
534
542
  if (local) this.patch(local.id, { providerId, ...patch });
535
- else this.append({ kind, providerId, ...patch });
543
+ else this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
536
544
  } else {
537
545
  this.append({ kind, providerId, ...(startedAtMs ? { createdAt: startedAtMs } : {}), ...patch });
538
546
  }
@@ -702,33 +710,41 @@ class CodexStructuredSession extends EventEmitter {
702
710
  text: prompt || '📷 Image attachment',
703
711
  attachments: images.map(item => ({ id: item.id, name: item.name || 'image' }))
704
712
  });
705
- await this.ensureProcess();
706
- if (!this.threadId) {
707
- const params = { cwd: this.workingDir };
713
+ try {
714
+ await this.ensureProcess();
715
+ if (!this.threadId) {
716
+ const params = { cwd: this.workingDir };
717
+ if (this.hasModelOverride) params.model = this.model;
718
+ if (this.permissionMode) params.approvalPolicy = this.permissionMode;
719
+ if (this.sandboxMode) params.sandbox = this.sandboxMode;
720
+ const started = await this.request('thread/start', params);
721
+ this.threadId = started.thread?.id;
722
+ this.model = started.model || this.model;
723
+ this.effort = started.reasoningEffort || this.effort;
724
+ this.effectivePermissionMode = started.approvalPolicy || this.effectivePermissionMode;
725
+ this.effectiveSandboxMode = sandboxModeFromPolicy(started.sandbox) || this.effectiveSandboxMode;
726
+ this.emitEvent({ type: 'state', state: this.getControlState() });
727
+ }
728
+ this.setStatus('running');
729
+ const input = [];
730
+ if (prompt) input.push({ type: 'text', text: prompt });
731
+ for (const image of images) input.push({ type: 'localImage', path: image.path });
732
+ const params = { threadId: this.threadId, input, cwd: this.workingDir, summary: 'auto' };
708
733
  if (this.hasModelOverride) params.model = this.model;
734
+ if (this.hasEffortOverride) params.effort = this.effort;
709
735
  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() });
736
+ const sandboxPolicy = sandboxPolicyFor(this.sandboxMode, this.workingDir);
737
+ if (sandboxPolicy) params.sandboxPolicy = sandboxPolicy;
738
+ const started = await this.request('turn/start', params);
739
+ this.currentTurnId = started?.turn?.id || started?.turnId || this.currentTurnId;
740
+ return true;
741
+ } catch (error) {
742
+ this.currentTurnId = null;
743
+ this.currentTurnStartedAt = null;
744
+ this.setStatus('idle');
745
+ this.append({ kind: 'event', level: 'error', text: `Unable to send message: ${error.message}` });
746
+ throw error;
718
747
  }
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
748
  }
733
749
 
734
750
  write(data) {
@@ -736,9 +752,7 @@ class CodexStructuredSession extends EventEmitter {
736
752
  const text = String(data || '').replace(/\r/g, '\n');
737
753
  const prompt = text.trim();
738
754
  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 });
755
+ this.logger.debugInfo?.(`[codex-app-server] send failed: ${error.message}`);
742
756
  });
743
757
  return true;
744
758
  }
@@ -830,7 +844,12 @@ class CodexStructuredSession extends EventEmitter {
830
844
  const startedAtMs = toMilliseconds(startedAt);
831
845
  const completedAtMs = toMilliseconds(completedAt);
832
846
  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');
847
+ for (const item of turn.items || []) {
848
+ this.applyProviderItem({ ...item, turnId: turn.id }, status === 'failed' ? 'failed' : 'completed', {
849
+ startedAtMs,
850
+ completedAtMs
851
+ });
852
+ }
834
853
  const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
835
854
  || (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
836
855
  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; }
@@ -327,7 +331,7 @@
327
331
  .codex-control-page .claude-ctrl-btn { height: 36px; padding-left: 4px; padding-right: 4px; }
328
332
  .codex-select-control { height: 36px; }
329
333
  #codex-chat-container { padding: 10px 10px calc(22px + env(safe-area-inset-bottom)); }
330
- .codex-message.user { max-width: 94%; }
334
+ .codex-message-block.user { max-width: 94%; }
331
335
  .claude-permission-actions { justify-content: flex-start; }
332
336
  }
333
337
  @media (max-width: 430px) {
@@ -451,7 +455,7 @@
451
455
  <button id="codex-status-btn" class="claude-ctrl-btn" onclick="requestCodexStatus()" title="Show Codex account, limits, and context status">Status</button>
452
456
  <button id="codex-abort-btn" class="claude-ctrl-btn danger" onclick="abortCodexSession()" title="Abort current Codex turn">Abort</button>
453
457
  <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>
458
+ <button id="codex-fork-btn" class="claude-ctrl-btn primary" onclick="toggleCodexForkPanel()" title="Fork a Codex thread in this conversation">Fork</button>
455
459
  </div>
456
460
  <div class="codex-control-page">
457
461
  <label class="codex-select-control" title="Sandbox mode">
@@ -2197,7 +2201,16 @@
2197
2201
  }).join('');
2198
2202
  const label = running ? 'Working' : failed ? 'Work finished with errors' : duration ? `Worked for ${duration}` : 'Worked';
2199
2203
  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>`;
2204
+ 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>`;
2205
+ }
2206
+ function renderCodexMessageTime(item, finalOnly = false) {
2207
+ if (!item || (finalOnly && item.streaming)) return '';
2208
+ const timestamp = Number(finalOnly ? (item.completedAtMs || item.updatedAt || item.createdAt) : item.createdAt);
2209
+ if (!Number.isFinite(timestamp) || timestamp <= 0) return '';
2210
+ const date = new Date(timestamp);
2211
+ if (Number.isNaN(date.getTime())) return '';
2212
+ const label = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
2213
+ return `<time class="codex-message-time" datetime="${escapeHtml(date.toISOString())}" title="${escapeHtml(date.toLocaleString())}">${escapeHtml(label)}</time>`;
2201
2214
  }
2202
2215
  function syncCodexDom(current, next) {
2203
2216
  if (!current || !next) return;
@@ -2243,6 +2256,10 @@
2243
2256
  function commitCodexChatRender() {
2244
2257
  const container = document.getElementById('codex-chat-container');
2245
2258
  if (!container) return;
2259
+ const wasEmpty = !container.firstElementChild;
2260
+ const previousScrollTop = container.scrollTop;
2261
+ const distanceFromBottom = container.scrollHeight - container.clientHeight - previousScrollTop;
2262
+ const shouldStickToBottom = wasEmpty || distanceFromBottom <= 64;
2246
2263
  const parts = [];
2247
2264
  const permissionById = new Map(codexPendingPermissions.map(item => [String(item.id), item]));
2248
2265
  const usedPermissions = new Set();
@@ -2255,17 +2272,12 @@
2255
2272
  const tools = [];
2256
2273
  const turnId = item.turnId;
2257
2274
  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,
2275
+ parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions,
2259
2276
  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
2277
  continue;
2266
2278
  }
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>`);
2279
+ 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>`);
2280
+ 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
2281
  else if (item.kind === 'status') parts.push(renderCodexStatus(item));
2270
2282
  else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
2271
2283
  i += 1;
@@ -2278,7 +2290,7 @@
2278
2290
  const current = container.firstElementChild;
2279
2291
  if (!current) container.appendChild(next);
2280
2292
  else syncCodexDom(current, next);
2281
- requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
2293
+ container.scrollTop = shouldStickToBottom ? container.scrollHeight : previousScrollTop;
2282
2294
  }
2283
2295
 
2284
2296
  function applyCodexState(state = {}) {
@@ -2307,7 +2319,7 @@
2307
2319
  const abort = document.getElementById('codex-abort-btn');
2308
2320
  if (abort) abort.disabled = !codexState.canAbort;
2309
2321
  const fork = document.getElementById('codex-fork-btn');
2310
- if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle' && codexState.threadId);
2322
+ if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle');
2311
2323
  const terminal = document.getElementById('codex-terminal-switch');
2312
2324
  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
2325
  renderCodexStateBar();
@@ -2393,7 +2405,7 @@
2393
2405
  await loadCodexThreadPanel(panel, 'resume');
2394
2406
  }
2395
2407
  async function toggleCodexForkPanel() {
2396
- if (!(codexState.presentation === 'structured' && codexState.status === 'idle' && codexState.threadId)) return;
2408
+ if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
2397
2409
  codexForkPanelOpen = !codexForkPanelOpen;
2398
2410
  codexModelPanelOpen = false;
2399
2411
  codexResumePanelOpen = false;
@@ -2429,7 +2441,7 @@
2429
2441
  }
2430
2442
  async function selectCodexForkThread(threadId) {
2431
2443
  const panel = document.getElementById('codex-fork-panel');
2432
- panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Forking into a new session...</div>';
2444
+ panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Forking and switching this conversation...</div>';
2433
2445
  updateTerminalControlsHeight();
2434
2446
  const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-fork`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 60000);
2435
2447
  const data = await res.json();
@@ -2442,7 +2454,6 @@
2442
2454
  panel.classList.remove('active');
2443
2455
  panel.innerHTML = '';
2444
2456
  updateTerminalControlsHeight();
2445
- refreshSessionsNow();
2446
2457
  }
2447
2458
  async function toggleCodexPresentation() {
2448
2459
  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.28",
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": {