glad-web 1.0.43 → 1.0.45

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.
@@ -11,6 +11,7 @@ const ClaudeStructuredSession = require('../claude/structured-session');
11
11
  const CodexStructuredSession = require('../codex/structured-session');
12
12
  const ClaudeTranscriptRepository = require('../claude/transcript-repository');
13
13
  const CodexImageStore = require('../codex/image-store');
14
+ const FileAttachmentStore = require('./file-attachment-store');
14
15
 
15
16
  function previewText(text, maxChars = 320) {
16
17
  if (!text) return '';
@@ -29,8 +30,16 @@ function imageMediaType(name) {
29
30
  : extension === '.gif' ? 'image/gif' : 'image/webp';
30
31
  }
31
32
 
33
+ function promptWithFileReferences(text, attachments) {
34
+ const prompt = String(text || '').trim();
35
+ if (!attachments.length) return prompt;
36
+ const references = attachments.map(item => `- ${item.name}: ${item.path}`).join('\n');
37
+ const note = `The user attached the following local files. Read them if relevant to the request:\n${references}`;
38
+ return prompt ? `${prompt}\n\n${note}` : note;
39
+ }
40
+
32
41
  class SessionManager extends EventEmitter {
33
- constructor({ baseDir, renderHistoryTools, debugHistoryEnabled = false, logger, hasConnectedSessionClient, claudeTranscriptRepository, codexImageStore, claudeForkSession } = {}) {
42
+ constructor({ baseDir, renderHistoryTools, debugHistoryEnabled = false, logger, hasConnectedSessionClient, claudeTranscriptRepository, codexImageStore, fileAttachmentStore, claudeForkSession } = {}) {
34
43
  super();
35
44
  this.baseDir = baseDir || process.cwd();
36
45
  this.renderHistoryTools = renderHistoryTools || new Set();
@@ -39,6 +48,7 @@ class SessionManager extends EventEmitter {
39
48
  this.hasConnectedSessionClient = hasConnectedSessionClient || (() => false);
40
49
  this.sessions = new Map();
41
50
  this.codexImages = codexImageStore || new CodexImageStore({ logger: this.logger });
51
+ this.fileAttachmentStore = fileAttachmentStore || new FileAttachmentStore({ logger: this.logger });
42
52
  Object.defineProperties(this, {
43
53
  codexImageRoot: {
44
54
  get: () => this.codexImages.root,
@@ -67,7 +77,7 @@ class SessionManager extends EventEmitter {
67
77
  startTime: session.startTime,
68
78
  toolKey: session.tool.key,
69
79
  workingDirectory: this.getSessionWorkingDirectory(session),
70
- mode: ['claude-structured', 'codex-structured'].includes(session.kind) ? (session.presentation || 'structured') : 'terminal',
80
+ mode: ['claude-structured', 'codex-structured'].includes(session.kind) ? 'structured' : 'terminal',
71
81
  hasUnreadCompletion: Boolean(session.hasUnreadCompletion),
72
82
  serverChanNotificationEnabled: Boolean(session.serverChanNotificationEnabled),
73
83
  timedInputCount: session.timedInputs
@@ -142,6 +152,8 @@ class SessionManager extends EventEmitter {
142
152
  hasConnectedWebClient: false,
143
153
  hasUnreadCompletion: false,
144
154
  timedInputs: new Map(),
155
+ fileAttachments: new Map(),
156
+ fileUploads: new Map(),
145
157
  write: data => this.write(id, data),
146
158
  isRunning: () => this.has(id) && ptyManager.isRunning(),
147
159
  kill: () => this.kill(id)
@@ -187,6 +199,8 @@ class SessionManager extends EventEmitter {
187
199
  });
188
200
  session.imageAttachments = new Map();
189
201
  session.imageUploads = new Map();
202
+ session.fileAttachments = new Map();
203
+ session.fileUploads = new Map();
190
204
 
191
205
  this.sessions.set(id, session);
192
206
  session.on('event', event => this.emit('claude-event', { sessionId: id, event, session }));
@@ -209,9 +223,10 @@ class SessionManager extends EventEmitter {
209
223
  const session = new CodexStructuredSession({ id, tool, workingDir: sessionDir, name: name || tool.displayName, logger: this.logger, options: codexOptions });
210
224
  session.imageAttachments = new Map();
211
225
  session.imageUploads = new Map();
226
+ session.fileAttachments = new Map();
227
+ session.fileUploads = new Map();
212
228
  this.sessions.set(id, session);
213
229
  session.on('event', event => this.emit('codex-event', { sessionId: id, event, session }));
214
- session.on('output', data => this.emit('output', { sessionId: id, data, session }));
215
230
  session.on('exit', () => this.handleExit(session));
216
231
  session.ensureProcess().catch(error => {
217
232
  session.append({ kind: 'event', level: 'error', text: `Unable to start Codex app-server: ${error.message}` });
@@ -228,12 +243,26 @@ class SessionManager extends EventEmitter {
228
243
  return session.ptyManager.write(data);
229
244
  }
230
245
 
231
- async sendClaudeInput(id, text, attachmentIds = []) {
246
+ sendTerminalFileInput(id, text, fileAttachmentIds = []) {
247
+ const session = this.get(id);
248
+ if (!session || ['claude-structured', 'codex-structured'].includes(session.kind)) return false;
249
+ const files = this.getFileAttachments(id, fileAttachmentIds);
250
+ const prompt = promptWithFileReferences(text, files);
251
+ if (!prompt) return false;
252
+ this.write(id, prompt.replace(/\n/g, '\r'));
253
+ const enterTimer = setTimeout(() => this.write(id, '\r'), 1000);
254
+ enterTimer.unref?.();
255
+ if (files.length) this.scheduleFileCleanup(id, files.map(item => item.id));
256
+ return true;
257
+ }
258
+
259
+ async sendClaudeInput(id, text, attachmentIds = [], fileAttachmentIds = []) {
232
260
  const session = this.get(id);
233
261
  if (!session || session.kind !== 'claude-structured') return false;
234
262
  const attachments = this.getImageAttachments(id, attachmentIds);
263
+ const files = this.getFileAttachments(id, fileAttachmentIds);
235
264
  const prompt = String(text || '');
236
- if (!prompt.trim() && attachments.length === 0) return false;
265
+ if (!prompt.trim() && attachments.length === 0 && files.length === 0) return false;
237
266
  const prepared = await Promise.all(attachments.map(async attachment => ({
238
267
  id: attachment.id,
239
268
  name: attachment.name,
@@ -241,12 +270,37 @@ class SessionManager extends EventEmitter {
241
270
  mediaType: imageMediaType(attachment.name),
242
271
  data: (await fs.promises.readFile(attachment.path)).toString('base64')
243
272
  })));
244
- this.markSessionInput(session, prompt || '[image attachment]');
245
- const sent = session.sendUserMessage(prompt, prepared);
273
+ const agentPrompt = promptWithFileReferences(prompt, files);
274
+ this.markSessionInput(session, prompt || (files.length ? '[file attachment]' : '[image attachment]'));
275
+ const sent = session.sendUserMessage(prompt, prepared, {
276
+ agentText: agentPrompt,
277
+ displayAttachments: files.map(item => ({ id: item.id, name: item.name, size: item.size, kind: 'file' }))
278
+ });
246
279
  if (sent && attachments.length) this.scheduleImageCleanup(id, attachments.map(item => item.id));
280
+ if (sent && files.length) this.scheduleFileCleanup(id, files.map(item => item.id));
247
281
  return sent;
248
282
  }
249
283
 
284
+ appendFileChunk(id, input = {}, bytes) {
285
+ return this.fileAttachmentStore.appendChunk(this.get(id), input, bytes);
286
+ }
287
+
288
+ discardFileUpload(id, uploadId) {
289
+ return this.fileAttachmentStore.discardUpload(this.get(id), uploadId);
290
+ }
291
+
292
+ discardFileAttachment(id, attachmentId) {
293
+ return this.fileAttachmentStore.discardAttachment(this.get(id), attachmentId);
294
+ }
295
+
296
+ getFileAttachments(id, attachmentIds = []) {
297
+ return this.fileAttachmentStore.resolve(this.get(id), attachmentIds);
298
+ }
299
+
300
+ scheduleFileCleanup(id, attachmentIds) {
301
+ this.fileAttachmentStore.scheduleCleanup(this.get(id), attachmentIds);
302
+ }
303
+
250
304
  storeImageAttachment(id, bytes) {
251
305
  return this.codexImages.store(this.get(id), bytes);
252
306
  }
@@ -295,15 +349,21 @@ class SessionManager extends EventEmitter {
295
349
  this.scheduleImageCleanup(id, attachmentIds);
296
350
  }
297
351
 
298
- async sendCodexInput(id, text, attachmentIds = [], skills = []) {
352
+ async sendCodexInput(id, text, attachmentIds = [], skills = [], fileAttachmentIds = []) {
299
353
  const session = this.get(id);
300
354
  if (!session || session.kind !== 'codex-structured') return false;
301
355
  const attachments = this.getCodexImageAttachments(id, attachmentIds);
356
+ const files = this.getFileAttachments(id, fileAttachmentIds);
302
357
  const prompt = String(text || '');
303
- if (!prompt.trim() && attachments.length === 0) return false;
304
- this.markSessionInput(session, prompt || '[image attachment]');
305
- const sent = await session.sendUserMessage(prompt, attachments, skills);
358
+ if (!prompt.trim() && attachments.length === 0 && files.length === 0) return false;
359
+ const agentPrompt = promptWithFileReferences(prompt, files);
360
+ this.markSessionInput(session, prompt || (files.length ? '[file attachment]' : '[image attachment]'));
361
+ const sent = await session.sendUserMessage(prompt, attachments, skills, {
362
+ agentText: agentPrompt,
363
+ displayAttachments: files.map(item => ({ id: item.id, name: item.name, size: item.size, kind: 'file' }))
364
+ });
306
365
  if (sent && attachments.length) this.scheduleCodexImageCleanup(id, attachments.map(item => item.id));
366
+ if (sent && files.length) this.scheduleFileCleanup(id, files.map(item => item.id));
307
367
  return sent;
308
368
  }
309
369
 
@@ -409,8 +469,8 @@ class SessionManager extends EventEmitter {
409
469
  async forkCodex(id, threadId) {
410
470
  const session = this.get(id);
411
471
  if (!session || session.kind !== 'codex-structured') return null;
412
- if (session.presentation !== 'structured' || session.status !== 'idle' || session.resuming) {
413
- const error = new Error('Codex must be idle in chat mode before forking');
472
+ if (session.status !== 'idle' || session.resuming) {
473
+ const error = new Error('Codex must be idle before forking');
414
474
  error.statusCode = 409;
415
475
  throw error;
416
476
  }
@@ -444,20 +504,11 @@ class SessionManager extends EventEmitter {
444
504
  return session.listSkills(forceReload);
445
505
  }
446
506
 
447
- switchCodexPresentation(id, presentation) {
448
- const session = this.get(id);
449
- if (!session || session.kind !== 'codex-structured') return Promise.resolve(false);
450
- return presentation === 'terminal' ? session.switchToTerminal() : session.switchToStructured();
451
- }
452
-
453
507
  resize(id, cols, rows) {
454
508
  const session = this.get(id);
455
509
  if (!session) return false;
456
510
  if (session.kind === 'claude-structured') return true;
457
- if (session.kind === 'codex-structured') {
458
- if (session.presentation === 'terminal') session.ptyManager.resize(cols, rows);
459
- return true;
460
- }
511
+ if (session.kind === 'codex-structured') return true;
461
512
  if (session.renderedHistory) {
462
513
  session.renderedHistory.resize(cols, rows);
463
514
  }
@@ -804,6 +855,7 @@ class SessionManager extends EventEmitter {
804
855
  this.clearTimedInputs(session);
805
856
  this.clearCodexImageUploads(session);
806
857
  this.clearCodexImageAttachments(session);
858
+ this.fileAttachmentStore.clear(session);
807
859
  this.disposeSessionHistory(session);
808
860
  this.sessions.delete(session.id);
809
861
  this.emit('exit', { sessionId: session.id, session });
package/lib/web/claude.js CHANGED
@@ -7,19 +7,34 @@
7
7
  }
8
8
 
9
9
  function setClaudeModeEnabled(enabled) {
10
- const codexChat = isCodexSession() && codexState.presentation === 'structured';
10
+ const codexChat = isCodexSession();
11
11
  const structured = enabled || codexChat;
12
+ const actionRail = enabled
13
+ ? document.querySelector('.claude-control-rail')
14
+ : codexChat
15
+ ? document.getElementById('codex-control-rail')
16
+ : document.getElementById('shortcut-rail');
17
+ const attachmentButton = document.getElementById('attachment-btn');
18
+ const scheduleButton = document.getElementById('schedule-send-btn');
19
+ if (actionRail && attachmentButton && scheduleButton) {
20
+ actionRail.prepend(scheduleButton);
21
+ actionRail.prepend(attachmentButton);
22
+ }
12
23
  document.getElementById('terminal-container').style.display = structured ? 'none' : '';
13
24
  document.getElementById('claude-chat-container').style.display = enabled ? 'block' : 'none';
14
25
  document.getElementById('codex-chat-container').style.display = codexChat ? 'block' : 'none';
15
- document.getElementById('claude-control-panel').style.display = enabled ? 'block' : 'none';
16
- document.getElementById('codex-control-panel').style.display = codexChat ? 'block' : 'none';
17
- document.getElementById('codex-terminal-switch').style.display = isCodexSession() ? '' : 'none';
18
- document.getElementById('timer-btn').style.display = '';
19
- document.getElementById('attach-image-btn').style.display = structured ? '' : 'none';
26
+ document.getElementById('claude-control-panel').style.display = enabled ? 'flex' : 'none';
27
+ document.getElementById('codex-control-panel').style.display = codexChat ? 'flex' : 'none';
28
+ document.getElementById('attachment-btn').style.display = '';
20
29
  document.getElementById('shortcut-rail').style.display = structured ? 'none' : '';
21
30
  document.getElementById('scroll-controls').style.display = structured ? 'none' : '';
22
31
  document.getElementById('cmd-input').placeholder = enabled ? 'Message Claude...' : codexChat ? 'Message Codex...' : 'Type a message...';
32
+ if (!codexChat) {
33
+ const skillPrefix = document.getElementById('composer-skill-prefix');
34
+ skillPrefix.classList.remove('active');
35
+ skillPrefix.innerHTML = '';
36
+ }
37
+ if (!structured) renderSessionAttention();
23
38
  updateTerminalControlsHeight();
24
39
  }
25
40
 
@@ -94,7 +109,7 @@
94
109
  const option = select.selectedOptions && select.selectedOptions[0];
95
110
  const label = pickerLabelFromOption(option);
96
111
  const fullValue = pickerFullValue(option);
97
- button.textContent = prefix;
112
+ setActionButtonLabel(button, prefix);
98
113
  button.title = type === 'model'
99
114
  ? `${fullValue || label || prefix} · ${effortLabel(claudeState.effort)}`
100
115
  : (fullValue || label || prefix);
@@ -252,12 +267,12 @@
252
267
  const usageBtn = document.getElementById('claude-usage-btn');
253
268
  if (usageBtn) {
254
269
  usageBtn.disabled = claudeUsagePending || claudeStatus === 'thinking';
255
- usageBtn.textContent = claudeUsagePending ? 'Loading' : 'Usage';
270
+ setActionButtonLabel(usageBtn, claudeUsagePending ? 'Loading' : 'Usage');
256
271
  }
257
272
  const contextBtn = document.getElementById('claude-context-btn');
258
273
  if (contextBtn) {
259
274
  contextBtn.disabled = claudeContextPending || claudeStatus === 'thinking';
260
- contextBtn.textContent = claudeContextPending ? 'Loading' : 'Context';
275
+ setActionButtonLabel(contextBtn, claudeContextPending ? 'Loading' : 'Context');
261
276
  }
262
277
  renderClaudeStateBar();
263
278
  renderClaudeChat();
@@ -304,8 +319,9 @@
304
319
  const pending = Number(claudeState.pendingPermissionCount || claudePendingPermissions.filter(item => item.status === 'pending').length) || 0;
305
320
  const mode = claudeState.permissionMode || 'default';
306
321
  const parts = [];
322
+ const attention = [];
307
323
  if (pending) {
308
- parts.push(`<button type="button" class="claude-state-pill warn claude-approval-jump" onclick="jumpToClaudeApproval()" title="Jump to pending approval" aria-label="Jump to pending Claude approval">${pending} approval${pending > 1 ? 's' : ''}<span aria-hidden="true">↓</span></button>`);
324
+ attention.push(`<button type="button" class="session-attention-pill approval claude-approval-jump" onclick="jumpToClaudeApproval()" title="Jump to pending approval" aria-label="Jump to pending Claude approval"><span aria-hidden="true">!</span>${pending} approval${pending > 1 ? 's' : ''}<span aria-hidden="true">↓</span></button>`);
309
325
  } else if (claudeStatus && !['idle', 'stopped', 'thinking'].includes(claudeStatus)) {
310
326
  parts.push(`<span class="claude-state-pill warn">${escapeHtml(shortValue(claudeStatus))}</span>`);
311
327
  }
@@ -314,6 +330,7 @@
314
330
  }
315
331
  el.innerHTML = parts.join('');
316
332
  el.style.display = parts.length ? 'flex' : 'none';
333
+ renderSessionAttention(attention);
317
334
  }
318
335
 
319
336
  function jumpToClaudeApproval() {
@@ -831,7 +848,7 @@
831
848
  const parsed = parseLocalCommandMessage(textFromClaudeMessage(message));
832
849
  if (parsed.kind === 'hidden') return '';
833
850
  const attachments = Array.isArray(message.attachments) && message.attachments.length
834
- ? `<div class="claude-message-attachments">${message.attachments.map(item => `<span class="claude-message-attachment" title="${escapeHtml(item.name || 'Image')}">▧ ${escapeHtml(item.name || 'Image')}</span>`).join('')}</div>` : '';
851
+ ? `<div class="claude-message-attachments">${message.attachments.map(item => `<span class="claude-message-attachment" title="${escapeHtml(item.name || 'Attachment')}"><svg class="message-attachment-icon action-icon" aria-hidden="true"><use href="#icon-${item.kind === 'file' ? 'file' : 'image'}"></use></svg>${escapeHtml(item.name || 'Attachment')}</span>`).join('')}</div>` : '';
835
852
  if (parsed.kind === 'command') {
836
853
  const args = parsed.args ? `<div class="claude-message user">${renderMarkdown(parsed.args)}</div>` : '';
837
854
  return `${args}<div class="claude-message user"><span class="claude-command-chip">/${escapeHtml(parsed.commandName)}</span>${attachments}</div>`;
package/lib/web/codex.js CHANGED
@@ -1,6 +1,6 @@
1
1
  function codexText(text) { return escapeHtml(text || '').replace(/\n/g, '<br>'); }
2
2
  function codexReadyForInput() {
3
- return codexState.presentation === 'structured' && codexState.status === 'idle'
3
+ return codexState.status === 'idle'
4
4
  && !codexState.aborting && !codexState.resuming && !codexResumeInFlight;
5
5
  }
6
6
  function codexJson(value) {
@@ -219,13 +219,30 @@
219
219
  }
220
220
  function renderCodexMessageSkills(item) {
221
221
  const skills = Array.isArray(item?.skills) ? item.skills : [];
222
- return skills.map(skill => `<span class="codex-message-skill" title="${escapeHtml(skill.path || skill.name || '')}">Skill · ${escapeHtml(skill.name || 'unknown')}</span>`).join('');
222
+ if (!skills.length) return '';
223
+ return `<div class="codex-message-skills">${skills.map(skill => `<span class="codex-message-skill" title="${escapeHtml(skill.path || skill.name || '')}">Skill · ${escapeHtml(skill.name || 'unknown')}</span>`).join('')}</div>`;
224
+ }
225
+
226
+ function renderComposerSkillPrefix() {
227
+ const container = document.getElementById('composer-skill-prefix');
228
+ if (!container) return;
229
+ const visible = selectedCodexSkill && codexReadyForInput();
230
+ const wasVisible = container.classList.contains('active');
231
+ container.classList.toggle('active', Boolean(visible));
232
+ container.innerHTML = visible
233
+ ? `<div class="codex-skill-bubble" role="status" aria-label="Selected skill: ${escapeHtml(selectedCodexSkill.name)}"><span class="codex-skill-bubble-name">Skill · ${escapeHtml(selectedCodexSkill.name)}</span><button type="button" class="codex-skill-bubble-close" onclick="clearCodexSkillSelection()" title="Remove selected skill" aria-label="Remove selected skill">×</button></div>`
234
+ : '';
235
+ if (wasVisible !== Boolean(visible)) updateTerminalControlsHeight();
236
+ }
237
+
238
+ function renderCodexMessageAttachments(item) {
239
+ if (!Array.isArray(item.attachments) || !item.attachments.length) return '';
240
+ return `<div class="claude-message-attachments">${item.attachments.map(attachment => `<span class="claude-message-attachment" title="${escapeHtml(attachment.name || 'Attachment')}"><svg class="message-attachment-icon action-icon" aria-hidden="true"><use href="#icon-${attachment.kind === 'file' ? 'file' : 'image'}"></use></svg>${escapeHtml(attachment.name || 'Attachment')}</span>`).join('')}</div>`;
223
241
  }
224
242
  function renderCodexMessageMeta(item, finalOnly = false, context = null) {
225
243
  const time = renderCodexMessageTime(item, finalOnly);
226
- const skills = renderCodexMessageSkills(item);
227
244
  const meter = renderCodexContextMeter(context);
228
- return time || skills || meter ? `<div class="codex-message-meta">${time}${skills}${meter}</div>` : '';
245
+ return time || meter ? `<div class="codex-message-meta">${time}${meter}</div>` : '';
229
246
  }
230
247
  function syncCodexDom(current, next) {
231
248
  if (!current || !next) return;
@@ -320,7 +337,7 @@
320
337
  const context = lastAssistantByTurn.get(turnId) === item.id ? turnEnd?.context : null;
321
338
  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>${renderCodexMessageMeta(item, true, context)}</div>`);
322
339
  }
323
- 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>${renderCodexMessageMeta(item)}</div>`);
340
+ else if (item.kind === 'user') parts.push(`<div class="codex-message-block user" data-codex-key="message-${escapeHtml(item.id || '')}">${renderCodexMessageSkills(item)}<div class="codex-message user claude-md">${renderMarkdown(item.text || '')}${renderCodexMessageAttachments(item)}</div>${renderCodexMessageMeta(item)}</div>`);
324
341
  else if (item.kind === 'compaction') parts.push(renderCodexCompaction(item));
325
342
  else if (item.kind === 'status') parts.push(renderCodexStatus(item));
326
343
  else if (item.kind === 'event' && item.level === 'warning') parts.push(renderCodexWarning(item));
@@ -328,16 +345,14 @@
328
345
  i += 1;
329
346
  }
330
347
  for (const request of codexPendingPermissions) if (!usedPermissions.has(request.id)) parts.push(renderCodexPermission(request));
331
- const skillBubble = selectedCodexSkill && codexReadyForInput()
332
- ? `<div class="codex-skill-bubble" role="status" aria-label="Selected skill: ${escapeHtml(selectedCodexSkill.name)}"><span class="codex-skill-bubble-name">Skill · ${escapeHtml(selectedCodexSkill.name)}</span><button type="button" class="codex-skill-bubble-close" onclick="clearCodexSkillSelection()" title="Remove selected skill" aria-label="Remove selected skill">×</button></div>`
333
- : '';
334
348
  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>`;
335
349
  const template = document.createElement('template');
336
- template.innerHTML = `<div class="codex-conversation">${skillBubble}${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
350
+ template.innerHTML = `<div class="codex-conversation">${working}${parts.join('') || '<div class="codex-message event">Send a message to start Codex.</div>'}</div>`;
337
351
  const next = template.content.firstElementChild;
338
352
  const current = container.firstElementChild;
339
353
  if (!current) container.appendChild(next);
340
354
  else syncCodexDom(current, next);
355
+ renderComposerSkillPrefix();
341
356
  container.scrollTop = shouldStickToBottom ? container.scrollHeight : previousScrollTop;
342
357
  }
343
358
 
@@ -424,7 +439,6 @@
424
439
  }
425
440
 
426
441
  function applyCodexState(state = {}) {
427
- const presentationChanged = state.presentation !== undefined && state.presentation !== codexState.presentation;
428
442
  codexState = { ...codexState, ...state };
429
443
  const permission = document.getElementById('codex-permission-select');
430
444
  if (permission) {
@@ -445,25 +459,22 @@
445
459
  sandbox.value = codexState.sandboxMode || 'default';
446
460
  }
447
461
  const modelButton = document.getElementById('codex-model-btn');
448
- if (modelButton) modelButton.textContent = 'Model';
462
+ if (modelButton) setActionButtonLabel(modelButton, 'Model');
449
463
  const abort = document.getElementById('codex-abort-btn');
450
464
  if (abort) {
451
465
  abort.disabled = !codexState.canAbort;
452
- abort.textContent = codexState.aborting ? 'Aborting…' : 'Abort';
466
+ setActionButtonLabel(abort, codexState.aborting ? 'Aborting…' : 'Abort');
453
467
  }
454
468
  const compact = document.getElementById('codex-compact-btn');
455
- if (compact) { compact.disabled = !codexState.canCompact; compact.textContent = codexState.compacting ? 'Compacting' : 'Compact'; }
469
+ if (compact) { compact.disabled = !codexState.canCompact; setActionButtonLabel(compact, codexState.compacting ? 'Compacting' : 'Compact'); }
456
470
  const skills = document.getElementById('codex-skills-btn');
457
471
  if (skills) { skills.disabled = !codexReadyForInput(); skills.classList.toggle('primary', Boolean(selectedCodexSkill)); }
458
472
  const resume = document.getElementById('codex-resume-btn');
459
473
  if (resume) resume.disabled = !codexReadyForInput();
460
474
  const fork = document.getElementById('codex-fork-btn');
461
475
  if (fork) fork.disabled = !codexReadyForInput();
462
- const terminal = document.getElementById('codex-terminal-switch');
463
- 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'; }
464
476
  renderCodexStateBar();
465
477
  renderCodexModelPanel();
466
- if (presentationChanged) setClaudeModeEnabled(isClaudeSession());
467
478
  renderCodexChat();
468
479
  }
469
480
 
@@ -473,12 +484,32 @@
473
484
  const pending = Number(codexState.pendingPermissionCount || codexPendingPermissions.filter(item => item.status === 'pending').length) || 0;
474
485
  const subagents = Number(codexState.activeSubagentCount || 0) || 0;
475
486
  const parts = [];
487
+ const attention = [];
476
488
  if (codexState.aborting) parts.push('<span class="claude-state-pill warn">Stopping Codex…</span>');
477
489
  else if (codexState.resuming || codexResumeInFlight) parts.push('<span class="claude-state-pill">Resuming conversation…</span>');
478
- if (pending || codexState.status === 'waiting_approval') parts.push(`<button type="button" class="claude-state-pill warn codex-approval-jump" onclick="jumpToCodexApproval()" title="Jump to pending approval" aria-label="Jump to pending approval">${pending || 1} approval${pending === 1 ? '' : 's'}<span aria-hidden="true">↓</span></button>`);
479
- if (subagents) parts.push(`<span class="claude-state-pill">${subagents} subagent${subagents === 1 ? '' : 's'} running</span>`);
490
+ if (pending || codexState.status === 'waiting_approval') attention.push(`<button type="button" class="session-attention-pill approval codex-approval-jump" onclick="jumpToCodexApproval()" title="Jump to pending approval" aria-label="Jump to pending approval"><span aria-hidden="true">!</span>${pending || 1} approval${pending === 1 ? '' : 's'}<span aria-hidden="true">↓</span></button>`);
491
+ if (subagents) attention.push(`<button type="button" class="session-attention-pill subagent codex-subagent-jump" onclick="jumpToActiveCodexSubagent()" title="Jump to active subagent" aria-label="Jump to active subagent"><span aria-hidden="true">↳</span>${subagents} subagent${subagents === 1 ? '' : 's'} running</button>`);
480
492
  el.innerHTML = parts.join('');
481
493
  el.style.display = parts.length ? 'flex' : 'none';
494
+ renderSessionAttention(attention);
495
+ }
496
+
497
+ function jumpToActiveCodexSubagent(retry = true) {
498
+ const groups = Array.from(document.querySelectorAll('.codex-subagent-group'));
499
+ const target = groups[groups.length - 1];
500
+ if (!target) {
501
+ if (!retry) return false;
502
+ commitCodexChatRender();
503
+ requestAnimationFrame(() => jumpToActiveCodexSubagent(false));
504
+ return false;
505
+ }
506
+ target.open = true;
507
+ target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
508
+ target.classList.remove('codex-approval-focus');
509
+ void target.offsetWidth;
510
+ target.classList.add('codex-approval-focus');
511
+ setTimeout(() => target.classList.remove('codex-approval-focus'), 1800);
512
+ return true;
482
513
  }
483
514
 
484
515
  function jumpToCodexApproval() {
@@ -1009,8 +1040,3 @@
1009
1040
  panel.innerHTML = '';
1010
1041
  updateTerminalControlsHeight();
1011
1042
  }
1012
- async function toggleCodexPresentation() {
1013
- const presentation = codexState.presentation === 'terminal' ? 'structured' : 'terminal';
1014
- const res = await fetchWithTimeout(`/api/sessions/${activeSessionId}/codex-presentation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ presentation }) }, 30000);
1015
- if (!res.ok) alert((await res.json()).error || 'Unable to switch Codex interface');
1016
- }