glad-web 1.0.26 → 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
  }
@@ -812,12 +826,17 @@ class CodexStructuredSession extends EventEmitter {
812
826
  this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
813
827
  this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
814
828
  const history = await this.request('thread/read', { threadId: this.threadId, includeTurns: true });
815
- this.model = history?.thread?.model || this.model;
816
- this.effort = history?.thread?.reasoningEffort || history?.thread?.reasoning_effort || this.effort;
817
- this.tokenUsage = history?.thread?.tokenUsage || history?.thread?.token_usage || this.tokenUsage;
829
+ this.restoreThreadHistory(history?.thread, `Resumed Codex thread ${this.threadId}`);
830
+ return true;
831
+ }
832
+
833
+ restoreThreadHistory(thread, eventText = '') {
834
+ this.model = thread?.model || this.model;
835
+ this.effort = thread?.reasoningEffort || thread?.reasoning_effort || this.effort;
836
+ this.tokenUsage = thread?.tokenUsage || thread?.token_usage || this.tokenUsage;
818
837
  this.messages = [];
819
838
  this.completedPermissions = [];
820
- for (const turn of history?.thread?.turns || []) {
839
+ for (const turn of thread?.turns || []) {
821
840
  const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
822
841
  const startedAt = Number(turn.startedAt || turn.createdAt || 0);
823
842
  const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
@@ -825,16 +844,40 @@ class CodexStructuredSession extends EventEmitter {
825
844
  const startedAtMs = toMilliseconds(startedAt);
826
845
  const completedAtMs = toMilliseconds(completedAt);
827
846
  this.append({ kind: 'turn-start', turnId: turn.id, ...(startedAtMs ? { createdAt: startedAtMs } : {}) });
828
- 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
+ }
829
853
  const durationMs = Number(turn.durationMs || turn.duration_ms || 0)
830
854
  || (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
831
855
  this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
832
856
  ...(completedAtMs ? { createdAt: completedAtMs } : {}) });
833
857
  }
834
- this.append({ kind: 'event', level: 'info', text: `Resumed Codex thread ${this.threadId}` });
858
+ if (eventText) this.append({ kind: 'event', level: 'info', text: eventText });
835
859
  this.emitEvent({ type: 'history-reset', messages: this.messages });
836
860
  this.emitEvent({ type: 'state', state: this.getControlState() });
837
- return true;
861
+ }
862
+
863
+ async forkFrom(threadId) {
864
+ const sourceThreadId = String(threadId || '').trim();
865
+ if (!sourceThreadId || this.presentation !== 'structured' || this.status !== 'idle') return false;
866
+ await this.ensureProcess();
867
+ const params = { threadId: sourceThreadId, cwd: this.workingDir, ephemeral: false, threadSource: null };
868
+ if (this.hasModelOverride) params.model = this.model;
869
+ if (this.permissionMode) params.approvalPolicy = this.permissionMode;
870
+ if (this.sandboxMode) params.sandbox = this.sandboxMode;
871
+ const result = await this.request('thread/fork', params);
872
+ const forkedThread = result?.thread;
873
+ if (!forkedThread?.id) throw new Error('Codex did not return a forked thread');
874
+ this.threadId = forkedThread.id;
875
+ this.model = result.model || forkedThread.model || this.model;
876
+ this.effort = result.reasoningEffort || forkedThread.reasoningEffort || forkedThread.reasoning_effort || this.effort;
877
+ this.effectivePermissionMode = result.approvalPolicy || this.effectivePermissionMode;
878
+ this.effectiveSandboxMode = sandboxModeFromPolicy(result.sandbox) || this.effectiveSandboxMode;
879
+ this.restoreThreadHistory(forkedThread, `Forked from Codex thread ${sourceThreadId}`);
880
+ return { threadId: this.threadId };
838
881
  }
839
882
 
840
883
  async switchToTerminal() {
@@ -379,6 +379,14 @@ async function webCommand(options) {
379
379
  } catch (e) { res.status(400).json({ error: e.message }); }
380
380
  });
381
381
 
382
+ app.post('/api/sessions/:id/codex-fork', async (req, res) => {
383
+ try {
384
+ const session = await sessionManager.forkCodex(req.params.id, req.body && req.body.threadId);
385
+ if (!session) return res.status(404).json({ error: 'Codex session not found' });
386
+ res.json({ success: true, id: session.id, name: session.name, threadId: session.threadId });
387
+ } catch (e) { res.status(e.statusCode || 400).json({ error: e.message }); }
388
+ });
389
+
382
390
  app.post('/api/sessions/:id/codex-presentation', async (req, res) => {
383
391
  const presentation = req.body && req.body.presentation;
384
392
  if (!['terminal', 'structured'].includes(presentation)) return res.status(400).json({ error: 'Invalid presentation' });
@@ -438,6 +438,26 @@ class SessionManager extends EventEmitter {
438
438
  return session && session.kind === 'codex-structured' ? session.resume(threadId) : false;
439
439
  }
440
440
 
441
+ async forkCodex(id, threadId) {
442
+ const session = this.get(id);
443
+ if (!session || session.kind !== 'codex-structured') return null;
444
+ if (session.presentation !== 'structured' || session.status !== 'idle') {
445
+ const error = new Error('Codex must be idle in chat mode before forking');
446
+ error.statusCode = 409;
447
+ throw error;
448
+ }
449
+ const sourceThreadId = String(threadId || session.threadId || '').trim();
450
+ if (!sourceThreadId) {
451
+ const error = new Error('Choose a Codex thread to fork');
452
+ error.statusCode = 400;
453
+ throw error;
454
+ }
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;
459
+ }
460
+
441
461
  listCodexResumeThreads(id) {
442
462
  const session = this.get(id);
443
463
  if (!session || session.kind !== 'codex-structured') return null;
@@ -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; }
@@ -143,16 +147,18 @@
143
147
  .codex-inline-permission .claude-permission-actions { margin-top: 8px; }
144
148
  @keyframes codex-spin { to { transform: rotate(360deg); } }
145
149
  #codex-control-panel { display: none; width: min(100%, var(--control-content-max)); margin: 0 auto; padding: 8px 14px 10px; border-bottom: 1px solid #222; background: #121212; box-sizing: border-box; }
146
- .codex-control-row { display: grid; grid-template-columns: repeat(6, minmax(0, 116px)); grid-template-rows: auto; justify-content: center; gap: clamp(2px, .8vw, 7px); align-items: center; white-space: nowrap; }
147
- .codex-control-row > * { width: 100%; min-width: 0; overflow: hidden; }
148
- .codex-control-row .claude-ctrl-btn, .codex-control-row .codex-select-label { padding-left: clamp(2px, 1vw, 10px); padding-right: clamp(2px, 1vw, 10px); font-size: clamp(8px, 2.2vw, 11px); text-overflow: ellipsis; overflow: hidden; }
150
+ .codex-control-rail { display: flex; gap: 14px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
151
+ .codex-control-rail::-webkit-scrollbar { display: none; }
152
+ .codex-control-page { flex: 0 0 100%; display: grid; grid-template-columns: repeat(5, minmax(0, 116px)); grid-template-rows: auto; justify-content: center; gap: clamp(2px, .8vw, 7px); align-items: center; white-space: nowrap; scroll-snap-align: start; box-sizing: border-box; }
153
+ .codex-control-page > * { width: 100%; min-width: 0; overflow: hidden; }
154
+ .codex-control-page .claude-ctrl-btn, .codex-control-page .codex-select-label { padding-left: clamp(2px, 1vw, 10px); padding-right: clamp(2px, 1vw, 10px); font-size: clamp(8px, 2.2vw, 11px); text-overflow: ellipsis; overflow: hidden; }
149
155
  .codex-select-control { position: relative; display: block; min-width: 0; height: 32px; }
150
156
  .codex-select-label { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; box-sizing: border-box; border: 1px solid rgba(255,255,255,.1); border-radius: 16px; background: rgba(255,255,255,.08); color: #f5f5f7; font-size: 11px; font-weight: 800; white-space: nowrap; }
151
157
  .codex-select-control:focus-within .codex-select-label { background: rgba(255,255,255,.14); border-color: rgba(0,122,255,.45); }
152
158
  .codex-select { appearance: none; position: absolute; inset: 0; width: 100%; min-width: 0; height: 100%; opacity: 0; cursor: pointer; }
153
159
  .codex-select option { background: #1c1c1e; color: #fff; }
154
- #codex-model-panel, #codex-resume-panel { display: none; margin-top: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(28,28,30,.99); border-radius: 8px; box-shadow: 0 16px 36px rgba(0,0,0,.34); overflow: hidden; }
155
- #codex-model-panel.active, #codex-resume-panel.active { display: block; }
160
+ #codex-model-panel, #codex-resume-panel, #codex-fork-panel { display: none; margin-top: 8px; border: 1px solid rgba(255,255,255,.1); background: rgba(28,28,30,.99); border-radius: 8px; box-shadow: 0 16px 36px rgba(0,0,0,.34); overflow: hidden; }
161
+ #codex-model-panel.active, #codex-resume-panel.active, #codex-fork-panel.active { display: block; }
156
162
  #codex-model-panel { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); max-height: min(300px, 42dvh); }
157
163
  #codex-model-panel.active { display: grid; }
158
164
  .codex-picker-column { min-width: 0; overflow-y: auto; }
@@ -161,7 +167,7 @@
161
167
  .codex-picker-option.selected { background: rgba(0,122,255,.18); color: #fff; }
162
168
  #codex-state-bar { display: flex; align-items: center; gap: 7px; min-height: 24px; margin-top: 8px; color: var(--text-dim); font-size: 11px; overflow-x: auto; scrollbar-width: none; white-space: nowrap; }
163
169
  #codex-state-bar::-webkit-scrollbar { display: none; }
164
- #codex-resume-panel { max-height: min(280px, 38dvh); overflow-y: auto; }
170
+ #codex-resume-panel, #codex-fork-panel { max-height: min(280px, 38dvh); overflow-y: auto; }
165
171
  .claude-context-size-badge { position: sticky; top: 0; z-index: 2; width: max-content; max-width: 100%; margin: 0 0 8px auto; border: 1px solid rgba(255,255,255,0.1); background: rgba(28,28,30,0.94); border-radius: 999px; padding: 4px 9px; color: #d1d5db; font-size: 11px; font-weight: 800; box-shadow: 0 8px 18px rgba(0,0,0,0.24); }
166
172
  .claude-message { max-width: 92%; margin: 0 0 10px 0; padding: 10px 12px; border-radius: 8px; overflow-wrap: anywhere; line-height: 1.45; font-size: 14px; }
167
173
  .claude-message.user { margin-left: auto; background: rgba(0,122,255,0.24); border: 1px solid rgba(0,122,255,0.32); color: #fff; border-radius: 12px; padding-top: 7px; padding-bottom: 7px; }
@@ -321,11 +327,11 @@
321
327
  #nav-bar > div:last-child .icon-btn { min-width: 34px; min-height: 30px; padding: 5px 7px !important; }
322
328
  #input-row { padding-left: 10px; padding-right: 10px; }
323
329
  #codex-control-panel { padding-left: 8px; padding-right: 8px; }
324
- .codex-control-row { grid-template-columns: repeat(6, minmax(0, 1fr)); }
325
- .codex-control-row .claude-ctrl-btn { height: 36px; padding-left: 4px; padding-right: 4px; }
330
+ .codex-control-page { grid-template-columns: repeat(5, minmax(0, 1fr)); }
331
+ .codex-control-page .claude-ctrl-btn { height: 36px; padding-left: 4px; padding-right: 4px; }
326
332
  .codex-select-control { height: 36px; }
327
333
  #codex-chat-container { padding: 10px 10px calc(22px + env(safe-area-inset-bottom)); }
328
- .codex-message.user { max-width: 94%; }
334
+ .codex-message-block.user { max-width: 94%; }
329
335
  .claude-permission-actions { justify-content: flex-start; }
330
336
  }
331
337
  @media (max-width: 430px) {
@@ -443,27 +449,33 @@
443
449
  <div id="claude-resume-panel"></div>
444
450
  </div>
445
451
  <div id="codex-control-panel">
446
- <div class="codex-control-row">
447
- <label class="codex-select-control" title="Sandbox mode">
448
- <span class="codex-select-label" aria-hidden="true">Sandbox</span>
449
- <select id="codex-sandbox-select" class="codex-select" aria-label="Sandbox mode" onchange="updateCodexSettingsFromControls()">
450
- <option value="default">Default</option><option value="read-only">Read only</option><option value="workspace-write">Workspace write</option><option value="danger-full-access">Full access</option>
451
- </select>
452
- </label>
453
- <label class="codex-select-control" title="Approval policy">
454
- <span class="codex-select-label" aria-hidden="true">Ask</span>
455
- <select id="codex-permission-select" class="codex-select" aria-label="Approval policy" onchange="updateCodexSettingsFromControls()">
456
- <option value="default">Default</option><option value="untrusted">Untrusted</option><option value="on-request">On request</option><option value="never">Never ask</option>
457
- </select>
458
- </label>
459
- <button id="codex-model-btn" class="claude-ctrl-btn" onclick="toggleCodexModelPanel()" title="Model and effort">Model</button>
460
- <button id="codex-status-btn" class="claude-ctrl-btn" onclick="requestCodexStatus()" title="Show Codex account, limits, and context status">Status</button>
461
- <button id="codex-abort-btn" class="claude-ctrl-btn danger" onclick="abortCodexSession()" title="Abort current Codex turn">Abort</button>
462
- <button id="codex-resume-btn" class="claude-ctrl-btn primary" onclick="toggleCodexResumePanel()" title="Resume a Codex thread">Resume</button>
452
+ <div id="codex-control-rail" class="codex-control-rail">
453
+ <div class="codex-control-page">
454
+ <button id="codex-model-btn" class="claude-ctrl-btn" onclick="toggleCodexModelPanel()" title="Model and effort">Model</button>
455
+ <button id="codex-status-btn" class="claude-ctrl-btn" onclick="requestCodexStatus()" title="Show Codex account, limits, and context status">Status</button>
456
+ <button id="codex-abort-btn" class="claude-ctrl-btn danger" onclick="abortCodexSession()" title="Abort current Codex turn">Abort</button>
457
+ <button id="codex-resume-btn" class="claude-ctrl-btn primary" onclick="toggleCodexResumePanel()" title="Resume a Codex thread">Resume</button>
458
+ <button id="codex-fork-btn" class="claude-ctrl-btn primary" onclick="toggleCodexForkPanel()" title="Fork a Codex thread in this conversation">Fork</button>
459
+ </div>
460
+ <div class="codex-control-page">
461
+ <label class="codex-select-control" title="Sandbox mode">
462
+ <span class="codex-select-label" aria-hidden="true">Sandbox</span>
463
+ <select id="codex-sandbox-select" class="codex-select" aria-label="Sandbox mode" onchange="updateCodexSettingsFromControls()">
464
+ <option value="default">Default</option><option value="read-only">Read only</option><option value="workspace-write">Workspace write</option><option value="danger-full-access">Full access</option>
465
+ </select>
466
+ </label>
467
+ <label class="codex-select-control" title="Approval policy">
468
+ <span class="codex-select-label" aria-hidden="true">Ask</span>
469
+ <select id="codex-permission-select" class="codex-select" aria-label="Approval policy" onchange="updateCodexSettingsFromControls()">
470
+ <option value="default">Default</option><option value="untrusted">Untrusted</option><option value="on-request">On request</option><option value="never">Never ask</option>
471
+ </select>
472
+ </label>
473
+ </div>
463
474
  </div>
464
475
  <div id="codex-state-bar"></div>
465
476
  <div id="codex-model-panel"></div>
466
477
  <div id="codex-resume-panel"></div>
478
+ <div id="codex-fork-panel"></div>
467
479
  </div>
468
480
  <div id="timed-send-panel">
469
481
  <div class="timed-row">
@@ -660,6 +672,7 @@
660
672
  let codexModelPanelOpen = false;
661
673
  let codexModelCandidate = null;
662
674
  let codexResumePanelOpen = false;
675
+ let codexForkPanelOpen = false;
663
676
  let codexRenderFrame = null;
664
677
  const modifiers = { ctrl: false };
665
678
 
@@ -2188,7 +2201,16 @@
2188
2201
  }).join('');
2189
2202
  const label = running ? 'Working' : failed ? 'Work finished with errors' : duration ? `Worked for ${duration}` : 'Worked';
2190
2203
  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>`;
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>`;
2192
2214
  }
2193
2215
  function syncCodexDom(current, next) {
2194
2216
  if (!current || !next) return;
@@ -2234,6 +2256,10 @@
2234
2256
  function commitCodexChatRender() {
2235
2257
  const container = document.getElementById('codex-chat-container');
2236
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;
2237
2263
  const parts = [];
2238
2264
  const permissionById = new Map(codexPendingPermissions.map(item => [String(item.id), item]));
2239
2265
  const usedPermissions = new Set();
@@ -2246,17 +2272,12 @@
2246
2272
  const tools = [];
2247
2273
  const turnId = item.turnId;
2248
2274
  while (i < visible.length && visible[i].kind === 'tool' && visible[i].turnId === turnId) tools.push(visible[i++]);
2249
- if (tools.length > 1) parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions,
2275
+ parts.push(renderCodexToolGroup(tools, permissionById, usedPermissions,
2250
2276
  turnEndById.get(String(turnId || ''))));
2251
- else {
2252
- const permission = permissionById.get(String(tools[0].providerId || ''));
2253
- if (permission) usedPermissions.add(permission.id);
2254
- parts.push(renderCodexTool(tools[0], permission));
2255
- }
2256
2277
  continue;
2257
2278
  }
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>`);
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>`);
2260
2281
  else if (item.kind === 'status') parts.push(renderCodexStatus(item));
2261
2282
  else parts.push(`<div class="codex-message ${escapeHtml(item.level === 'error' ? 'event error' : item.kind || 'event')}">${codexText(item.text)}</div>`);
2262
2283
  i += 1;
@@ -2269,7 +2290,7 @@
2269
2290
  const current = container.firstElementChild;
2270
2291
  if (!current) container.appendChild(next);
2271
2292
  else syncCodexDom(current, next);
2272
- requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
2293
+ container.scrollTop = shouldStickToBottom ? container.scrollHeight : previousScrollTop;
2273
2294
  }
2274
2295
 
2275
2296
  function applyCodexState(state = {}) {
@@ -2297,6 +2318,8 @@
2297
2318
  if (modelButton) modelButton.textContent = 'Model';
2298
2319
  const abort = document.getElementById('codex-abort-btn');
2299
2320
  if (abort) abort.disabled = !codexState.canAbort;
2321
+ const fork = document.getElementById('codex-fork-btn');
2322
+ if (fork) fork.disabled = !(codexState.presentation === 'structured' && codexState.status === 'idle');
2300
2323
  const terminal = document.getElementById('codex-terminal-switch');
2301
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'; }
2302
2325
  renderCodexStateBar();
@@ -2320,8 +2343,10 @@
2320
2343
  function toggleCodexModelPanel() {
2321
2344
  codexModelPanelOpen = !codexModelPanelOpen;
2322
2345
  codexResumePanelOpen = false;
2346
+ codexForkPanelOpen = false;
2323
2347
  codexModelCandidate = codexState.model || codexState.models?.[0]?.id || null;
2324
2348
  document.getElementById('codex-resume-panel').classList.remove('active');
2349
+ document.getElementById('codex-fork-panel').classList.remove('active');
2325
2350
  renderCodexModelPanel();
2326
2351
  updateTerminalControlsHeight();
2327
2352
  }
@@ -2370,11 +2395,29 @@
2370
2395
  async function toggleCodexResumePanel() {
2371
2396
  codexResumePanelOpen = !codexResumePanelOpen;
2372
2397
  codexModelPanelOpen = false;
2398
+ codexForkPanelOpen = false;
2373
2399
  document.getElementById('codex-model-panel').classList.remove('active');
2400
+ document.getElementById('codex-fork-panel').classList.remove('active');
2374
2401
  const panel = document.getElementById('codex-resume-panel');
2375
2402
  panel.classList.toggle('active', codexResumePanelOpen);
2376
2403
  updateTerminalControlsHeight();
2377
2404
  if (!codexResumePanelOpen) return;
2405
+ await loadCodexThreadPanel(panel, 'resume');
2406
+ }
2407
+ async function toggleCodexForkPanel() {
2408
+ if (!(codexState.presentation === 'structured' && codexState.status === 'idle')) return;
2409
+ codexForkPanelOpen = !codexForkPanelOpen;
2410
+ codexModelPanelOpen = false;
2411
+ codexResumePanelOpen = false;
2412
+ document.getElementById('codex-model-panel').classList.remove('active');
2413
+ document.getElementById('codex-resume-panel').classList.remove('active');
2414
+ const panel = document.getElementById('codex-fork-panel');
2415
+ panel.classList.toggle('active', codexForkPanelOpen);
2416
+ updateTerminalControlsHeight();
2417
+ if (!codexForkPanelOpen) return;
2418
+ await loadCodexThreadPanel(panel, 'fork');
2419
+ }
2420
+ async function loadCodexThreadPanel(panel, action) {
2378
2421
  panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Loading sessions...</div>';
2379
2422
  try {
2380
2423
  const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume-threads`, {}, 30000);
@@ -2383,9 +2426,11 @@
2383
2426
  const items = data.items || [];
2384
2427
  panel.innerHTML = items.length ? items.map(item => {
2385
2428
  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>`;
2429
+ const handler = action === 'fork' ? 'selectCodexForkThread' : 'selectCodexResumeThread';
2430
+ return `<button class="claude-resume-item" onclick="${handler}(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
2431
  }).join('') : '<div class="claude-resume-meta" style="padding:12px;">No Codex sessions found for this folder.</div>';
2388
2432
  } catch (e) { panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(e.message)}</div>`; }
2433
+ updateTerminalControlsHeight();
2389
2434
  }
2390
2435
  async function selectCodexResumeThread(threadId) {
2391
2436
  const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-resume`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 30000);
@@ -2394,6 +2439,22 @@
2394
2439
  document.getElementById('codex-resume-panel').classList.remove('active');
2395
2440
  updateTerminalControlsHeight();
2396
2441
  }
2442
+ async function selectCodexForkThread(threadId) {
2443
+ const panel = document.getElementById('codex-fork-panel');
2444
+ panel.innerHTML = '<div class="claude-resume-meta" style="padding:12px;">Forking and switching this conversation...</div>';
2445
+ updateTerminalControlsHeight();
2446
+ const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-fork`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threadId }) }, 60000);
2447
+ const data = await res.json();
2448
+ if (!res.ok || !data.success) {
2449
+ panel.innerHTML = `<div class="claude-resume-meta" style="padding:12px;color:#ff6b61;">${escapeHtml(data.error || 'Unable to fork Codex thread')}</div>`;
2450
+ updateTerminalControlsHeight();
2451
+ return;
2452
+ }
2453
+ codexForkPanelOpen = false;
2454
+ panel.classList.remove('active');
2455
+ panel.innerHTML = '';
2456
+ updateTerminalControlsHeight();
2457
+ }
2397
2458
  async function toggleCodexPresentation() {
2398
2459
  const presentation = codexState.presentation === 'terminal' ? 'structured' : 'terminal';
2399
2460
  const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-presentation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ presentation }) }, 30000);
@@ -2528,8 +2589,11 @@
2528
2589
  codexModelPanelOpen = false;
2529
2590
  codexModelCandidate = null;
2530
2591
  codexResumePanelOpen = false;
2592
+ codexForkPanelOpen = false;
2531
2593
  document.getElementById('codex-model-panel').classList.remove('active');
2532
2594
  document.getElementById('codex-resume-panel').classList.remove('active');
2595
+ document.getElementById('codex-fork-panel').classList.remove('active');
2596
+ document.getElementById('codex-control-rail').scrollLeft = 0;
2533
2597
  codexState = { permissionMode: 'default', sandboxMode: 'default', effectivePermissionMode: null, effectiveSandboxMode: null, model: null, effort: null, status: 'idle', threadId: null, presentation: 'structured', models: [], canAbort: false, canSwitchToTerminal: false, canSwitchToStructured: false };
2534
2598
  setClaudeModeEnabled(false);
2535
2599
  applyCodexState(codexState);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glad-web",
3
- "version": "1.0.26",
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": {