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.
@@ -1,31 +1,124 @@
1
1
  const inputEl = document.getElementById('cmd-input');
2
- const imageFileInput = document.getElementById('image-file-input');
2
+ const attachmentFileInput = document.getElementById('attachment-file-input');
3
3
  const attachmentStrip = document.getElementById('attachment-strip');
4
4
  let selectedImageAttachments = [];
5
+ let selectedFileAttachments = [];
5
6
  let keepTerminalBottomForNextInput = false;
7
+ const composerActionTooltip = document.getElementById('composer-action-tooltip');
8
+ let composerActionTooltipTimer = null;
9
+ let composerTouchGesture = null;
10
+ let suppressTouchFocusUntil = 0;
11
+
12
+ function composerActionControl(target) {
13
+ const control = target instanceof Element ? target.closest('button, label') : null;
14
+ if (!control || !document.getElementById('terminal-controls').contains(control)) return null;
15
+ return control.querySelector('.action-icon') ? control : null;
16
+ }
17
+
18
+ function composerActionName(control) {
19
+ return control?.querySelector('.action-label')?.textContent?.trim()
20
+ || control?.getAttribute('aria-label')
21
+ || control?.getAttribute('title')
22
+ || '';
23
+ }
24
+
25
+ function hideComposerActionTooltip() {
26
+ clearTimeout(composerActionTooltipTimer);
27
+ composerActionTooltipTimer = null;
28
+ composerActionTooltip.classList.remove('visible');
29
+ }
30
+
31
+ function showComposerActionTooltip(control, autoHide = false) {
32
+ const label = composerActionName(control);
33
+ if (!label) return;
34
+ clearTimeout(composerActionTooltipTimer);
35
+ composerActionTooltip.textContent = label;
36
+ composerActionTooltip.classList.add('visible');
37
+ const controlBox = control.getBoundingClientRect();
38
+ const tooltipBox = composerActionTooltip.getBoundingClientRect();
39
+ const left = Math.max(8, Math.min(window.innerWidth - tooltipBox.width - 8,
40
+ controlBox.left + controlBox.width / 2 - tooltipBox.width / 2));
41
+ const above = controlBox.top - tooltipBox.height - 8;
42
+ composerActionTooltip.style.left = `${left}px`;
43
+ composerActionTooltip.style.top = `${above >= 8 ? above : controlBox.bottom + 8}px`;
44
+ if (autoHide) composerActionTooltipTimer = setTimeout(hideComposerActionTooltip, 1400);
45
+ }
46
+
47
+ const terminalControls = document.getElementById('terminal-controls');
48
+ terminalControls.addEventListener('focusin', event => {
49
+ if (performance.now() < suppressTouchFocusUntil) return;
50
+ const control = composerActionControl(event.target);
51
+ if (control) showComposerActionTooltip(control);
52
+ });
53
+ terminalControls.addEventListener('focusout', hideComposerActionTooltip);
54
+ terminalControls.addEventListener('pointerover', event => {
55
+ const control = composerActionControl(event.target);
56
+ if (control && event.pointerType !== 'touch') showComposerActionTooltip(control);
57
+ });
58
+ terminalControls.addEventListener('pointerout', event => {
59
+ const control = composerActionControl(event.target);
60
+ if (control && !control.contains(event.relatedTarget)) hideComposerActionTooltip();
61
+ });
62
+ terminalControls.addEventListener('pointerdown', event => {
63
+ const control = composerActionControl(event.target);
64
+ if (control && event.pointerType === 'touch') {
65
+ composerTouchGesture = { pointerId: event.pointerId, control, x: event.clientX, y: event.clientY, moved: false };
66
+ suppressTouchFocusUntil = performance.now() + 800;
67
+ hideComposerActionTooltip();
68
+ }
69
+ });
70
+ terminalControls.addEventListener('pointermove', event => {
71
+ if (!composerTouchGesture || event.pointerId !== composerTouchGesture.pointerId) return;
72
+ if (Math.hypot(event.clientX - composerTouchGesture.x, event.clientY - composerTouchGesture.y) > 8) {
73
+ composerTouchGesture.moved = true;
74
+ hideComposerActionTooltip();
75
+ }
76
+ });
77
+ terminalControls.addEventListener('pointerup', event => {
78
+ if (!composerTouchGesture || event.pointerId !== composerTouchGesture.pointerId) return;
79
+ const gesture = composerTouchGesture;
80
+ composerTouchGesture = null;
81
+ suppressTouchFocusUntil = performance.now() + 800;
82
+ if (!gesture.moved) showComposerActionTooltip(gesture.control, true);
83
+ });
84
+ terminalControls.addEventListener('pointercancel', () => {
85
+ composerTouchGesture = null;
86
+ hideComposerActionTooltip();
87
+ });
88
+ terminalControls.addEventListener('scroll', hideComposerActionTooltip, true);
6
89
 
7
90
  function isStructuredImageAttachmentAvailable() {
8
- return isClaudeSession() || (isCodexSession() && codexState.presentation === 'structured');
91
+ return isClaudeSession() || isCodexSession();
92
+ }
93
+
94
+ function isSupportedImageFile(file) {
95
+ const type = String(file?.type || '').toLowerCase();
96
+ const name = String(file?.name || '').toLowerCase();
97
+ return ['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(type)
98
+ || /\.(png|jpe?g|gif|webp)$/.test(name);
9
99
  }
10
100
 
11
101
  function syncComposerButtonState() {
12
- const menuOpen = document.getElementById('composer-menu').classList.contains('active');
13
102
  const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
14
- document.getElementById('timer-btn').classList.toggle('active', menuOpen || timerOpen);
103
+ document.getElementById('schedule-send-btn').classList.toggle('active', timerOpen);
104
+ document.getElementById('attachment-btn').classList.toggle('active', selectedImageAttachments.length + selectedFileAttachments.length > 0);
15
105
  }
16
106
 
17
- function renderImageAttachments() {
18
- attachmentStrip.innerHTML = selectedImageAttachments.map(item => (
19
- `<div class="attachment-chip${item.uploading ? ' uploading' : ''}"><span aria-hidden="true">▧</span><span class="attachment-chip-content"><span class="attachment-chip-name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</span>${item.uploading ? `<span class="attachment-progress${item.progressKnown ? '' : ' estimated'}"><span style="width:${Math.max(0, Math.min(100, item.progress || 0))}%"></span></span><span class="attachment-status">${escapeHtml(item.status || (item.progressKnown ? `Uploading ${Math.round(item.progress || 0)}%` : 'Uploading original image…'))}</span>` : ''}</span><button class="attachment-remove" type="button" title="Remove image" aria-label="Remove ${escapeHtml(item.name)}" onclick="removeImageAttachment('${item.id}')">×</button></div>`
20
- )).join('');
21
- attachmentStrip.classList.toggle('active', selectedImageAttachments.length > 0);
107
+ function renderComposerAttachments() {
108
+ const chip = (item, kind) => `<div class="attachment-chip ${kind}${item.uploading ? ' uploading' : ''}"><svg class="attachment-chip-icon action-icon" aria-hidden="true"><use href="#icon-${kind === 'image' ? 'image' : 'file'}"></use></svg><span class="attachment-chip-content"><span class="attachment-chip-name" title="${escapeHtml(item.name)}">${escapeHtml(item.name)}</span>${item.uploading ? `<span class="attachment-progress${item.progressKnown ? '' : ' estimated'}"><span style="width:${Math.max(0, Math.min(100, item.progress || 0))}%"></span></span><span class="attachment-status">${escapeHtml(item.status || `Uploading ${kind}…`)}</span>` : ''}</span><button class="attachment-remove" type="button" title="Remove ${kind}" aria-label="Remove ${escapeHtml(item.name)}" onclick="${kind === 'image' ? 'removeImageAttachment' : 'removeFileAttachment'}('${item.id}')">×</button></div>`;
109
+ attachmentStrip.innerHTML = [
110
+ ...selectedImageAttachments.map(item => chip(item, 'image')),
111
+ ...selectedFileAttachments.map(item => chip(item, 'file'))
112
+ ].join('');
113
+ attachmentStrip.classList.toggle('active', selectedImageAttachments.length + selectedFileAttachments.length > 0);
114
+ syncComposerButtonState();
22
115
  updateTerminalControlsHeight();
23
116
  }
24
117
 
25
118
  window.removeImageAttachment = async function(attachmentId) {
26
119
  const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
27
120
  selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
28
- renderImageAttachments();
121
+ renderComposerAttachments();
29
122
  if (!attachment) return;
30
123
  clearInterval(attachment.indicatorTimer);
31
124
  attachment.abortUpload?.();
@@ -37,24 +130,43 @@
37
130
  }
38
131
  };
39
132
 
40
- async function clearImageAttachments() {
133
+ window.removeFileAttachment = async function(attachmentId) {
134
+ const attachment = selectedFileAttachments.find(item => item.id === attachmentId);
135
+ selectedFileAttachments = selectedFileAttachments.filter(item => item.id !== attachmentId);
136
+ renderComposerAttachments();
137
+ if (!attachment) return;
138
+ attachment.abortUpload?.();
139
+ if (attachment.uploading) return;
140
+ try {
141
+ await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/files/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
142
+ } catch (_) {}
143
+ };
144
+
145
+ async function clearComposerAttachments() {
41
146
  const pending = selectedImageAttachments;
147
+ const pendingFiles = selectedFileAttachments;
42
148
  selectedImageAttachments = [];
43
- renderImageAttachments();
149
+ selectedFileAttachments = [];
150
+ renderComposerAttachments();
44
151
  for (const item of pending) item.abortUpload?.();
152
+ for (const item of pendingFiles) item.abortUpload?.();
45
153
  await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
46
154
  `/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
47
155
  { method: 'DELETE' }
48
156
  ).catch(() => null)));
157
+ await Promise.all(pendingFiles.filter(item => !item.uploading).map(item => fetchWithTimeout(
158
+ `/api/sessions/${item.sessionId}/attachments/files/${encodeURIComponent(item.id)}`,
159
+ { method: 'DELETE' }
160
+ ).catch(() => null)));
49
161
  }
50
162
 
51
- const IMAGE_UPLOAD_CHUNK_BYTES = 512 * 1024;
163
+ const ATTACHMENT_UPLOAD_CHUNK_BYTES = 512 * 1024;
52
164
 
53
165
  function uploadImageInChunks(sessionId, file, onProgress) {
54
166
  let xhr = null;
55
167
  let cancelled = false;
56
168
  const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
57
- const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
169
+ const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
58
170
  return {
59
171
  abort: () => {
60
172
  cancelled = true;
@@ -64,8 +176,8 @@
64
176
  promise: (async () => {
65
177
  for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
66
178
  if (cancelled) throw new Error('Image upload cancelled');
67
- const start = chunkIndex * IMAGE_UPLOAD_CHUNK_BYTES;
68
- const chunk = file.slice(start, Math.min(file.size, start + IMAGE_UPLOAD_CHUNK_BYTES));
179
+ const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
180
+ const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
69
181
  const result = await new Promise((resolve, reject) => {
70
182
  xhr = new XMLHttpRequest();
71
183
  xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
@@ -110,7 +222,7 @@
110
222
  alert(`${file.name} is larger than 50 MB.`);
111
223
  continue;
112
224
  }
113
- const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
225
+ const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
114
226
  const pending = {
115
227
  id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
116
228
  name: file.name || 'image',
@@ -122,12 +234,12 @@
122
234
  abortUpload: null
123
235
  };
124
236
  selectedImageAttachments.push(pending);
125
- renderImageAttachments();
237
+ renderComposerAttachments();
126
238
  try {
127
239
  const upload = uploadImageInChunks(activeSessionId, file, progress => {
128
240
  pending.progress = progress;
129
241
  pending.status = `${progress}%`;
130
- renderImageAttachments();
242
+ renderComposerAttachments();
131
243
  });
132
244
  pending.abortUpload = upload.abort;
133
245
  const attachment = await upload.promise;
@@ -137,24 +249,122 @@
137
249
  continue;
138
250
  }
139
251
  selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
140
- renderImageAttachments();
252
+ renderComposerAttachments();
141
253
  } catch (e) {
142
254
  selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
143
- renderImageAttachments();
255
+ renderComposerAttachments();
144
256
  if (e.message === 'Image upload cancelled') continue;
145
257
  alert(`Could not add ${file.name}: ${e.message}`);
146
258
  }
147
259
  }
148
260
  }
149
261
 
150
- function closeComposerMenu() {
151
- document.getElementById('composer-menu').classList.remove('active');
152
- syncComposerButtonState();
153
- updateTerminalControlsHeight();
262
+ function uploadFileInChunks(sessionId, file, onProgress) {
263
+ let xhr = null;
264
+ let cancelled = false;
265
+ const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
266
+ const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
267
+ return {
268
+ abort: () => {
269
+ cancelled = true;
270
+ xhr?.abort();
271
+ void fetch(`/api/sessions/${sessionId}/attachments/files/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
272
+ },
273
+ promise: (async () => {
274
+ for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
275
+ if (cancelled) throw new Error('File upload cancelled');
276
+ const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
277
+ const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
278
+ const result = await new Promise((resolve, reject) => {
279
+ xhr = new XMLHttpRequest();
280
+ xhr.open('POST', `/api/sessions/${sessionId}/attachments/files/chunks`);
281
+ xhr.timeout = 60_000;
282
+ xhr.setRequestHeader('Content-Type', 'application/octet-stream');
283
+ xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
284
+ xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
285
+ xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
286
+ xhr.setRequestHeader('X-Glad-File-Name', encodeURIComponent(file.name || 'attachment.bin'));
287
+ xhr.onerror = () => reject(new Error('Network error while uploading file'));
288
+ xhr.ontimeout = () => reject(new Error(`File upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
289
+ xhr.onabort = () => reject(new Error('File upload cancelled'));
290
+ xhr.onload = () => {
291
+ let data = {};
292
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
293
+ if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
294
+ reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
295
+ return;
296
+ }
297
+ resolve(data);
298
+ };
299
+ xhr.send(chunk);
300
+ });
301
+ onProgress(Math.round((Math.min(file.size, start + chunk.size) / file.size) * 100));
302
+ if (result.complete) return result.attachment;
303
+ }
304
+ throw new Error('File upload did not complete');
305
+ })()
306
+ };
307
+ }
308
+
309
+ async function uploadAttachmentFiles(files) {
310
+ const remaining = 8 - selectedFileAttachments.length;
311
+ const batch = Array.from(files).slice(0, remaining);
312
+ if (files.length > remaining) alert('You can attach up to 8 files at a time.');
313
+ for (const file of batch) {
314
+ if (!file.size) {
315
+ alert(`${file.name || 'File'} is empty.`);
316
+ continue;
317
+ }
318
+ if (file.size > 50 * 1024 * 1024) {
319
+ alert(`${file.name} is larger than 50 MB.`);
320
+ continue;
321
+ }
322
+ const pending = {
323
+ id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
324
+ name: file.name || 'attachment.bin',
325
+ sessionId: activeSessionId,
326
+ uploading: true,
327
+ progress: 0,
328
+ progressKnown: true,
329
+ status: '0%',
330
+ abortUpload: null
331
+ };
332
+ selectedFileAttachments.push(pending);
333
+ renderComposerAttachments();
334
+ try {
335
+ const upload = uploadFileInChunks(activeSessionId, file, progress => {
336
+ pending.progress = progress;
337
+ pending.status = `${progress}%`;
338
+ renderComposerAttachments();
339
+ });
340
+ pending.abortUpload = upload.abort;
341
+ const attachment = await upload.promise;
342
+ const index = selectedFileAttachments.indexOf(pending);
343
+ if (index < 0) {
344
+ await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/files/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
345
+ continue;
346
+ }
347
+ selectedFileAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
348
+ renderComposerAttachments();
349
+ } catch (e) {
350
+ selectedFileAttachments = selectedFileAttachments.filter(item => item !== pending);
351
+ renderComposerAttachments();
352
+ if (e.message === 'File upload cancelled') continue;
353
+ alert(`Could not add ${file.name}: ${e.message}`);
354
+ }
355
+ }
356
+ }
357
+
358
+ async function uploadSelectedAttachments(files) {
359
+ const selected = Array.from(files || []);
360
+ const images = isStructuredImageAttachmentAvailable()
361
+ ? selected.filter(isSupportedImageFile) : [];
362
+ const regularFiles = selected.filter(file => !images.includes(file));
363
+ if (images.length) await uploadImageFiles(images);
364
+ if (regularFiles.length) await uploadAttachmentFiles(regularFiles);
154
365
  }
155
366
 
156
367
  function openTimedSendPanel() {
157
- closeComposerMenu();
158
368
  if (isClaudeSession()) {
159
369
  closeClaudePicker();
160
370
  claudeResumePanelOpen = false;
@@ -187,43 +397,62 @@
187
397
  function performSend() {
188
398
  const val = inputEl.value;
189
399
  const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
190
- if (selectedImageAttachments.some(item => item.uploading)) {
191
- alert('Wait for image uploads to finish before sending.');
400
+ const readyFileAttachments = selectedFileAttachments.filter(item => !item.uploading);
401
+ if (selectedImageAttachments.some(item => item.uploading) || selectedFileAttachments.some(item => item.uploading)) {
402
+ alert('Wait for attachments to finish uploading before sending.');
192
403
  return;
193
404
  }
194
- if ((val || readyImageAttachments.length) && isClaudeSession()) {
405
+ if ((val || readyImageAttachments.length || readyFileAttachments.length) && isClaudeSession()) {
195
406
  if (currentSocket && currentSocket.readyState === 1) {
196
407
  currentSocket.send(JSON.stringify({
197
408
  type: 'claude-input',
198
409
  text: val,
199
- attachmentIds: readyImageAttachments.map(item => item.id)
410
+ attachmentIds: readyImageAttachments.map(item => item.id),
411
+ ...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {})
200
412
  }));
201
413
  }
202
414
  inputEl.value = '';
203
415
  inputEl.style.height = '38px';
204
416
  selectedImageAttachments = [];
205
- renderImageAttachments();
417
+ selectedFileAttachments = [];
418
+ renderComposerAttachments();
206
419
  return;
207
420
  }
208
- if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
421
+ if ((val || readyImageAttachments.length || readyFileAttachments.length) && isCodexSession()) {
209
422
  if (!codexReadyForInput()) return;
210
423
  if (currentSocket && currentSocket.readyState === 1) {
211
424
  currentSocket.send(JSON.stringify({
212
425
  type: 'codex-input',
213
426
  text: val,
214
427
  attachmentIds: readyImageAttachments.map(item => item.id),
428
+ ...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {}),
215
429
  skills: selectedCodexSkill ? [{ name: selectedCodexSkill.name, path: selectedCodexSkill.path }] : []
216
430
  }));
217
431
  }
218
432
  inputEl.value = '';
219
433
  inputEl.style.height = '38px';
220
434
  selectedImageAttachments = [];
435
+ selectedFileAttachments = [];
221
436
  selectedCodexSkill = null;
222
- renderImageAttachments();
437
+ renderComposerAttachments();
223
438
  renderCodexChat();
224
439
  return;
225
440
  }
226
- if (val) {
441
+ if (val || readyFileAttachments.length) {
442
+ if (readyFileAttachments.length) {
443
+ if (currentSocket && currentSocket.readyState === 1) {
444
+ currentSocket.send(JSON.stringify({
445
+ type: 'file-input',
446
+ text: val,
447
+ fileAttachmentIds: readyFileAttachments.map(item => item.id)
448
+ }));
449
+ }
450
+ inputEl.value = '';
451
+ inputEl.style.height = '38px';
452
+ selectedFileAttachments = [];
453
+ renderComposerAttachments();
454
+ return;
455
+ }
227
456
  const formattedVal = val.replace(/\n/g, '\r');
228
457
  sendWS(formattedVal);
229
458
  inputEl.value = '';
package/lib/web/core.js CHANGED
@@ -9,6 +9,22 @@
9
9
  let timedSendRefreshTimer = null;
10
10
  let timedTagTimer = null;
11
11
  let editingTimedInputId = null;
12
+ function setActionButtonLabel(button, label) {
13
+ if (!button) return;
14
+ const labelNode = button.querySelector('.action-label');
15
+ if (labelNode) {
16
+ labelNode.textContent = label;
17
+ return;
18
+ }
19
+ button.textContent = label;
20
+ }
21
+ function renderSessionAttention(parts = []) {
22
+ const strip = document.getElementById('session-status-strip');
23
+ const rail = document.getElementById('session-attention-rail');
24
+ if (!strip || !rail) return;
25
+ rail.innerHTML = parts.join('');
26
+ strip.style.display = parts.length ? 'block' : 'none';
27
+ }
12
28
  let claudeMessages = [];
13
29
  let claudePendingPermissions = [];
14
30
  let claudeStatus = 'idle';
@@ -35,9 +51,8 @@
35
51
  permissionMode: 'default', sandboxMode: 'default',
36
52
  effectivePermissionMode: null, effectiveSandboxMode: null,
37
53
  model: null, effort: null, status: 'idle', threadId: null,
38
- presentation: 'structured', models: [], aborting: false, resuming: false,
39
- canAbort: false, canCompact: false, compacting: false,
40
- canSwitchToTerminal: false, canSwitchToStructured: false
54
+ models: [], aborting: false, resuming: false,
55
+ canAbort: false, canCompact: false, compacting: false
41
56
  };
42
57
  }
43
58
  let codexMessages = [];
@@ -235,7 +250,7 @@
235
250
  ${timedInputCount}
236
251
  </span>`
237
252
  : '';
238
- html += `<div class="session-card">
253
+ html += `<div class="session-card${s.id === activeSessionId ? ' selected' : ''}" data-session-id="${escapeHtml(s.id)}">
239
254
  <div class="session-info">
240
255
  <h3>${escapeHtml(s.name)}${s.hasUnreadCompletion ? '<span class="completion-dot" title="Completed"></span>' : ''}${timerBadge} <button type="button" class="icon-btn session-edit-btn" title="Rename session" aria-label="Rename session" onclick="renameSession('${s.id}', decodePathValue('${encodedName}'), event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg></button></h3>
241
256
  <p>${escapeHtml(s.tool)}</p>
@@ -316,11 +331,12 @@
316
331
  }
317
332
 
318
333
  function closeToolModal(e) {
319
- if (e.target.id === 'modal-overlay') document.getElementById('modal-overlay').style.display = 'none';
334
+ if (!e || e.target.id === 'modal-overlay') document.getElementById('modal-overlay').style.display = 'none';
320
335
  }
321
336
 
322
337
  function isLobbyVisible() {
323
- return document.getElementById('lobby-view').classList.contains('active');
338
+ return document.getElementById('lobby-view').classList.contains('active')
339
+ || (typeof isSplitLayout === 'function' && isSplitLayout());
324
340
  }
325
341
 
326
342
  function scheduleSessionPolling() {