glad-web 1.0.44 → 1.0.46

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,24 +1,143 @@
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
+ let composerSendPending = false;
12
+
13
+ function composerExecutionInProgress() {
14
+ if (composerSendPending) return true;
15
+ if (isClaudeSession()) return claudeStatus === 'thinking';
16
+ if (isCodexSession()) return !codexReadyForInput();
17
+ return false;
18
+ }
19
+
20
+ function syncComposerSendState({ acknowledgeProviderState = false } = {}) {
21
+ if (acknowledgeProviderState) composerSendPending = false;
22
+ const sendButton = document.getElementById('send-btn');
23
+ const executing = composerExecutionInProgress();
24
+ sendButton.disabled = executing;
25
+ sendButton.title = executing ? 'Wait for the current run to finish' : 'Send';
26
+ sendButton.setAttribute('aria-label', executing ? 'Send disabled while running' : 'Send');
27
+ }
28
+
29
+ function markComposerSendPending() {
30
+ composerSendPending = true;
31
+ syncComposerSendState();
32
+ }
33
+
34
+ function resetComposerSendState() {
35
+ composerSendPending = false;
36
+ syncComposerSendState();
37
+ }
38
+
39
+ function composerActionControl(target) {
40
+ const control = target instanceof Element ? target.closest('button, label') : null;
41
+ if (!control || !document.getElementById('terminal-controls').contains(control)) return null;
42
+ return control.querySelector('.action-icon') ? control : null;
43
+ }
44
+
45
+ function composerActionName(control) {
46
+ return control?.querySelector('.action-label')?.textContent?.trim()
47
+ || control?.getAttribute('aria-label')
48
+ || control?.getAttribute('title')
49
+ || '';
50
+ }
51
+
52
+ function hideComposerActionTooltip() {
53
+ clearTimeout(composerActionTooltipTimer);
54
+ composerActionTooltipTimer = null;
55
+ composerActionTooltip.classList.remove('visible');
56
+ }
57
+
58
+ function showComposerActionTooltip(control, autoHide = false) {
59
+ const label = composerActionName(control);
60
+ if (!label) return;
61
+ clearTimeout(composerActionTooltipTimer);
62
+ composerActionTooltip.textContent = label;
63
+ composerActionTooltip.classList.add('visible');
64
+ const controlBox = control.getBoundingClientRect();
65
+ const tooltipBox = composerActionTooltip.getBoundingClientRect();
66
+ const left = Math.max(8, Math.min(window.innerWidth - tooltipBox.width - 8,
67
+ controlBox.left + controlBox.width / 2 - tooltipBox.width / 2));
68
+ const above = controlBox.top - tooltipBox.height - 8;
69
+ composerActionTooltip.style.left = `${left}px`;
70
+ composerActionTooltip.style.top = `${above >= 8 ? above : controlBox.bottom + 8}px`;
71
+ if (autoHide) composerActionTooltipTimer = setTimeout(hideComposerActionTooltip, 1400);
72
+ }
73
+
74
+ const terminalControls = document.getElementById('terminal-controls');
75
+ terminalControls.addEventListener('focusin', event => {
76
+ if (performance.now() < suppressTouchFocusUntil) return;
77
+ const control = composerActionControl(event.target);
78
+ if (control) showComposerActionTooltip(control);
79
+ });
80
+ terminalControls.addEventListener('focusout', hideComposerActionTooltip);
81
+ terminalControls.addEventListener('pointerover', event => {
82
+ const control = composerActionControl(event.target);
83
+ if (control && event.pointerType !== 'touch') showComposerActionTooltip(control);
84
+ });
85
+ terminalControls.addEventListener('pointerout', event => {
86
+ const control = composerActionControl(event.target);
87
+ if (control && !control.contains(event.relatedTarget)) hideComposerActionTooltip();
88
+ });
89
+ terminalControls.addEventListener('pointerdown', event => {
90
+ const control = composerActionControl(event.target);
91
+ if (control && event.pointerType === 'touch') {
92
+ composerTouchGesture = { pointerId: event.pointerId, control, x: event.clientX, y: event.clientY, moved: false };
93
+ suppressTouchFocusUntil = performance.now() + 800;
94
+ hideComposerActionTooltip();
95
+ }
96
+ });
97
+ terminalControls.addEventListener('pointermove', event => {
98
+ if (!composerTouchGesture || event.pointerId !== composerTouchGesture.pointerId) return;
99
+ if (Math.hypot(event.clientX - composerTouchGesture.x, event.clientY - composerTouchGesture.y) > 8) {
100
+ composerTouchGesture.moved = true;
101
+ hideComposerActionTooltip();
102
+ }
103
+ });
104
+ terminalControls.addEventListener('pointerup', event => {
105
+ if (!composerTouchGesture || event.pointerId !== composerTouchGesture.pointerId) return;
106
+ const gesture = composerTouchGesture;
107
+ composerTouchGesture = null;
108
+ suppressTouchFocusUntil = performance.now() + 800;
109
+ if (!gesture.moved) showComposerActionTooltip(gesture.control, true);
110
+ });
111
+ terminalControls.addEventListener('pointercancel', () => {
112
+ composerTouchGesture = null;
113
+ hideComposerActionTooltip();
114
+ });
115
+ terminalControls.addEventListener('scroll', hideComposerActionTooltip, true);
6
116
 
7
117
  function isStructuredImageAttachmentAvailable() {
8
- return isClaudeSession() || (isCodexSession() && codexState.presentation === 'structured');
118
+ return isClaudeSession() || isCodexSession();
119
+ }
120
+
121
+ function isSupportedImageFile(file) {
122
+ const type = String(file?.type || '').toLowerCase();
123
+ const name = String(file?.name || '').toLowerCase();
124
+ return ['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(type)
125
+ || /\.(png|jpe?g|gif|webp)$/.test(name);
9
126
  }
10
127
 
11
128
  function syncComposerButtonState() {
12
129
  const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
13
130
  document.getElementById('schedule-send-btn').classList.toggle('active', timerOpen);
14
- document.getElementById('attach-image-btn').classList.toggle('active', selectedImageAttachments.length > 0);
131
+ document.getElementById('attachment-btn').classList.toggle('active', selectedImageAttachments.length + selectedFileAttachments.length > 0);
15
132
  }
16
133
 
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);
134
+ function renderComposerAttachments() {
135
+ 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>`;
136
+ attachmentStrip.innerHTML = [
137
+ ...selectedImageAttachments.map(item => chip(item, 'image')),
138
+ ...selectedFileAttachments.map(item => chip(item, 'file'))
139
+ ].join('');
140
+ attachmentStrip.classList.toggle('active', selectedImageAttachments.length + selectedFileAttachments.length > 0);
22
141
  syncComposerButtonState();
23
142
  updateTerminalControlsHeight();
24
143
  }
@@ -26,7 +145,7 @@
26
145
  window.removeImageAttachment = async function(attachmentId) {
27
146
  const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
28
147
  selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
29
- renderImageAttachments();
148
+ renderComposerAttachments();
30
149
  if (!attachment) return;
31
150
  clearInterval(attachment.indicatorTimer);
32
151
  attachment.abortUpload?.();
@@ -38,24 +157,43 @@
38
157
  }
39
158
  };
40
159
 
41
- async function clearImageAttachments() {
160
+ window.removeFileAttachment = async function(attachmentId) {
161
+ const attachment = selectedFileAttachments.find(item => item.id === attachmentId);
162
+ selectedFileAttachments = selectedFileAttachments.filter(item => item.id !== attachmentId);
163
+ renderComposerAttachments();
164
+ if (!attachment) return;
165
+ attachment.abortUpload?.();
166
+ if (attachment.uploading) return;
167
+ try {
168
+ await fetchWithTimeout(`/api/sessions/${attachment.sessionId}/attachments/files/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
169
+ } catch (_) {}
170
+ };
171
+
172
+ async function clearComposerAttachments() {
42
173
  const pending = selectedImageAttachments;
174
+ const pendingFiles = selectedFileAttachments;
43
175
  selectedImageAttachments = [];
44
- renderImageAttachments();
176
+ selectedFileAttachments = [];
177
+ renderComposerAttachments();
45
178
  for (const item of pending) item.abortUpload?.();
179
+ for (const item of pendingFiles) item.abortUpload?.();
46
180
  await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
47
181
  `/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
48
182
  { method: 'DELETE' }
49
183
  ).catch(() => null)));
184
+ await Promise.all(pendingFiles.filter(item => !item.uploading).map(item => fetchWithTimeout(
185
+ `/api/sessions/${item.sessionId}/attachments/files/${encodeURIComponent(item.id)}`,
186
+ { method: 'DELETE' }
187
+ ).catch(() => null)));
50
188
  }
51
189
 
52
- const IMAGE_UPLOAD_CHUNK_BYTES = 512 * 1024;
190
+ const ATTACHMENT_UPLOAD_CHUNK_BYTES = 512 * 1024;
53
191
 
54
192
  function uploadImageInChunks(sessionId, file, onProgress) {
55
193
  let xhr = null;
56
194
  let cancelled = false;
57
195
  const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
58
- const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
196
+ const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
59
197
  return {
60
198
  abort: () => {
61
199
  cancelled = true;
@@ -65,8 +203,8 @@
65
203
  promise: (async () => {
66
204
  for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
67
205
  if (cancelled) throw new Error('Image upload cancelled');
68
- const start = chunkIndex * IMAGE_UPLOAD_CHUNK_BYTES;
69
- const chunk = file.slice(start, Math.min(file.size, start + IMAGE_UPLOAD_CHUNK_BYTES));
206
+ const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
207
+ const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
70
208
  const result = await new Promise((resolve, reject) => {
71
209
  xhr = new XMLHttpRequest();
72
210
  xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
@@ -111,7 +249,7 @@
111
249
  alert(`${file.name} is larger than 50 MB.`);
112
250
  continue;
113
251
  }
114
- const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
252
+ const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
115
253
  const pending = {
116
254
  id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
117
255
  name: file.name || 'image',
@@ -123,12 +261,12 @@
123
261
  abortUpload: null
124
262
  };
125
263
  selectedImageAttachments.push(pending);
126
- renderImageAttachments();
264
+ renderComposerAttachments();
127
265
  try {
128
266
  const upload = uploadImageInChunks(activeSessionId, file, progress => {
129
267
  pending.progress = progress;
130
268
  pending.status = `${progress}%`;
131
- renderImageAttachments();
269
+ renderComposerAttachments();
132
270
  });
133
271
  pending.abortUpload = upload.abort;
134
272
  const attachment = await upload.promise;
@@ -138,16 +276,121 @@
138
276
  continue;
139
277
  }
140
278
  selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
141
- renderImageAttachments();
279
+ renderComposerAttachments();
142
280
  } catch (e) {
143
281
  selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
144
- renderImageAttachments();
282
+ renderComposerAttachments();
145
283
  if (e.message === 'Image upload cancelled') continue;
146
284
  alert(`Could not add ${file.name}: ${e.message}`);
147
285
  }
148
286
  }
149
287
  }
150
288
 
289
+ function uploadFileInChunks(sessionId, file, onProgress) {
290
+ let xhr = null;
291
+ let cancelled = false;
292
+ const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
293
+ const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
294
+ return {
295
+ abort: () => {
296
+ cancelled = true;
297
+ xhr?.abort();
298
+ void fetch(`/api/sessions/${sessionId}/attachments/files/uploads/${encodeURIComponent(uploadId)}`, { method: 'DELETE' }).catch(() => null);
299
+ },
300
+ promise: (async () => {
301
+ for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
302
+ if (cancelled) throw new Error('File upload cancelled');
303
+ const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
304
+ const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
305
+ const result = await new Promise((resolve, reject) => {
306
+ xhr = new XMLHttpRequest();
307
+ xhr.open('POST', `/api/sessions/${sessionId}/attachments/files/chunks`);
308
+ xhr.timeout = 60_000;
309
+ xhr.setRequestHeader('Content-Type', 'application/octet-stream');
310
+ xhr.setRequestHeader('X-Glad-Upload-Id', uploadId);
311
+ xhr.setRequestHeader('X-Glad-Chunk-Index', String(chunkIndex));
312
+ xhr.setRequestHeader('X-Glad-Chunk-Total', String(chunkTotal));
313
+ xhr.setRequestHeader('X-Glad-File-Name', encodeURIComponent(file.name || 'attachment.bin'));
314
+ xhr.onerror = () => reject(new Error('Network error while uploading file'));
315
+ xhr.ontimeout = () => reject(new Error(`File upload timed out on chunk ${chunkIndex + 1}/${chunkTotal}`));
316
+ xhr.onabort = () => reject(new Error('File upload cancelled'));
317
+ xhr.onload = () => {
318
+ let data = {};
319
+ try { data = JSON.parse(xhr.responseText || '{}'); } catch (_) {}
320
+ if (xhr.status < 200 || xhr.status >= 300 || !data.success) {
321
+ reject(new Error(data.error || `Upload failed (HTTP ${xhr.status})`));
322
+ return;
323
+ }
324
+ resolve(data);
325
+ };
326
+ xhr.send(chunk);
327
+ });
328
+ onProgress(Math.round((Math.min(file.size, start + chunk.size) / file.size) * 100));
329
+ if (result.complete) return result.attachment;
330
+ }
331
+ throw new Error('File upload did not complete');
332
+ })()
333
+ };
334
+ }
335
+
336
+ async function uploadAttachmentFiles(files) {
337
+ const remaining = 8 - selectedFileAttachments.length;
338
+ const batch = Array.from(files).slice(0, remaining);
339
+ if (files.length > remaining) alert('You can attach up to 8 files at a time.');
340
+ for (const file of batch) {
341
+ if (!file.size) {
342
+ alert(`${file.name || 'File'} is empty.`);
343
+ continue;
344
+ }
345
+ if (file.size > 50 * 1024 * 1024) {
346
+ alert(`${file.name} is larger than 50 MB.`);
347
+ continue;
348
+ }
349
+ const pending = {
350
+ id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
351
+ name: file.name || 'attachment.bin',
352
+ sessionId: activeSessionId,
353
+ uploading: true,
354
+ progress: 0,
355
+ progressKnown: true,
356
+ status: '0%',
357
+ abortUpload: null
358
+ };
359
+ selectedFileAttachments.push(pending);
360
+ renderComposerAttachments();
361
+ try {
362
+ const upload = uploadFileInChunks(activeSessionId, file, progress => {
363
+ pending.progress = progress;
364
+ pending.status = `${progress}%`;
365
+ renderComposerAttachments();
366
+ });
367
+ pending.abortUpload = upload.abort;
368
+ const attachment = await upload.promise;
369
+ const index = selectedFileAttachments.indexOf(pending);
370
+ if (index < 0) {
371
+ await fetchWithTimeout(`/api/sessions/${activeSessionId}/attachments/files/${encodeURIComponent(attachment.id)}`, { method: 'DELETE' });
372
+ continue;
373
+ }
374
+ selectedFileAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
375
+ renderComposerAttachments();
376
+ } catch (e) {
377
+ selectedFileAttachments = selectedFileAttachments.filter(item => item !== pending);
378
+ renderComposerAttachments();
379
+ if (e.message === 'File upload cancelled') continue;
380
+ alert(`Could not add ${file.name}: ${e.message}`);
381
+ }
382
+ }
383
+ }
384
+
385
+ async function uploadSelectedAttachments(files) {
386
+ const selected = Array.from(files || []);
387
+ const images = isStructuredImageAttachmentAvailable()
388
+ ? selected.filter(isSupportedImageFile) : [];
389
+ const regularFiles = selected.filter(file => !images.includes(file));
390
+ if (images.length) await uploadImageFiles(images);
391
+ if (regularFiles.length) await uploadAttachmentFiles(regularFiles);
392
+ }
393
+
151
394
  function openTimedSendPanel() {
152
395
  if (isClaudeSession()) {
153
396
  closeClaudePicker();
@@ -179,45 +422,67 @@
179
422
  });
180
423
 
181
424
  function performSend() {
425
+ if (document.getElementById('send-btn').disabled) return;
182
426
  const val = inputEl.value;
183
427
  const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
184
- if (selectedImageAttachments.some(item => item.uploading)) {
185
- alert('Wait for image uploads to finish before sending.');
428
+ const readyFileAttachments = selectedFileAttachments.filter(item => !item.uploading);
429
+ if (selectedImageAttachments.some(item => item.uploading) || selectedFileAttachments.some(item => item.uploading)) {
430
+ alert('Wait for attachments to finish uploading before sending.');
186
431
  return;
187
432
  }
188
- if ((val || readyImageAttachments.length) && isClaudeSession()) {
433
+ if ((val || readyImageAttachments.length || readyFileAttachments.length) && isClaudeSession()) {
189
434
  if (currentSocket && currentSocket.readyState === 1) {
190
435
  currentSocket.send(JSON.stringify({
191
436
  type: 'claude-input',
192
437
  text: val,
193
- attachmentIds: readyImageAttachments.map(item => item.id)
438
+ attachmentIds: readyImageAttachments.map(item => item.id),
439
+ ...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {})
194
440
  }));
441
+ markComposerSendPending();
195
442
  }
196
443
  inputEl.value = '';
197
444
  inputEl.style.height = '38px';
198
445
  selectedImageAttachments = [];
199
- renderImageAttachments();
446
+ selectedFileAttachments = [];
447
+ renderComposerAttachments();
200
448
  return;
201
449
  }
202
- if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
450
+ if ((val || readyImageAttachments.length || readyFileAttachments.length) && isCodexSession()) {
203
451
  if (!codexReadyForInput()) return;
204
452
  if (currentSocket && currentSocket.readyState === 1) {
205
453
  currentSocket.send(JSON.stringify({
206
454
  type: 'codex-input',
207
455
  text: val,
208
456
  attachmentIds: readyImageAttachments.map(item => item.id),
457
+ ...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {}),
209
458
  skills: selectedCodexSkill ? [{ name: selectedCodexSkill.name, path: selectedCodexSkill.path }] : []
210
459
  }));
460
+ markComposerSendPending();
211
461
  }
212
462
  inputEl.value = '';
213
463
  inputEl.style.height = '38px';
214
464
  selectedImageAttachments = [];
465
+ selectedFileAttachments = [];
215
466
  selectedCodexSkill = null;
216
- renderImageAttachments();
467
+ renderComposerAttachments();
217
468
  renderCodexChat();
218
469
  return;
219
470
  }
220
- if (val) {
471
+ if (val || readyFileAttachments.length) {
472
+ if (readyFileAttachments.length) {
473
+ if (currentSocket && currentSocket.readyState === 1) {
474
+ currentSocket.send(JSON.stringify({
475
+ type: 'file-input',
476
+ text: val,
477
+ fileAttachmentIds: readyFileAttachments.map(item => item.id)
478
+ }));
479
+ }
480
+ inputEl.value = '';
481
+ inputEl.style.height = '38px';
482
+ selectedFileAttachments = [];
483
+ renderComposerAttachments();
484
+ return;
485
+ }
221
486
  const formattedVal = val.replace(/\n/g, '\r');
222
487
  sendWS(formattedVal);
223
488
  inputEl.value = '';
package/lib/web/core.js CHANGED
@@ -18,6 +18,13 @@
18
18
  }
19
19
  button.textContent = label;
20
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
+ }
21
28
  let claudeMessages = [];
22
29
  let claudePendingPermissions = [];
23
30
  let claudeStatus = 'idle';
@@ -44,9 +51,8 @@
44
51
  permissionMode: 'default', sandboxMode: 'default',
45
52
  effectivePermissionMode: null, effectiveSandboxMode: null,
46
53
  model: null, effort: null, status: 'idle', threadId: null,
47
- presentation: 'structured', models: [], aborting: false, resuming: false,
48
- canAbort: false, canCompact: false, compacting: false,
49
- canSwitchToTerminal: false, canSwitchToStructured: false
54
+ models: [], aborting: false, resuming: false,
55
+ canAbort: false, canCompact: false, compacting: false
50
56
  };
51
57
  }
52
58
  let codexMessages = [];
@@ -220,6 +226,40 @@
220
226
  }
221
227
  }
222
228
 
229
+ function renderSessionCard(session) {
230
+ const workingDirectory = session.workingDirectory || 'Unknown directory';
231
+ const encodedId = encodePathValue(session.id);
232
+ const encodedName = encodePathValue(session.name);
233
+ const encodedDir = encodePathValue(workingDirectory);
234
+ const encodedToolKey = encodePathValue(session.toolKey || '');
235
+ const timedInputCount = Number(session.timedInputCount) || 0;
236
+ const timerBadge = timedInputCount > 0
237
+ ? `<span class="timer-count-badge" title="${timedInputCount} scheduled timer${timedInputCount > 1 ? 's' : ''}">
238
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
239
+ ${timedInputCount}
240
+ </span>`
241
+ : '';
242
+ return `<div class="session-card${session.id === activeSessionId ? ' selected' : ''}" data-session-id="${escapeHtml(session.id)}">
243
+ <span class="active-session-dot" title="Current session" aria-label="Current session"></span>
244
+ <div class="session-info">
245
+ <h3><span class="session-name" title="${escapeHtml(session.name)}">${escapeHtml(session.name)}</span>${session.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(decodePathValue('${encodedId}'), 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>
246
+ <p>${escapeHtml(session.tool)}</p>
247
+ <p>${new Date(session.startTime).toLocaleTimeString()}</p>
248
+ </div>
249
+ <div class="session-actions">
250
+ ${renderServerChanSessionAction(session)}
251
+ <button class="btn-join" onclick="joinSession(decodePathValue('${encodedId}'), decodePathValue('${encodedName}'), decodePathValue('${encodedToolKey}'))">Connect</button>
252
+ <button type="button" class="icon-btn btn-delete session-delete-btn" title="Delete session" aria-label="Delete session" onclick="deleteSession(decodePathValue('${encodedId}'), event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
253
+ </div>
254
+ <div class="session-dir-row">
255
+ <button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
256
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
257
+ </button>
258
+ <p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
259
+ </div>
260
+ </div>`;
261
+ }
262
+
223
263
  async function loadSessions() {
224
264
  log('Loading sessions...');
225
265
  const list = document.getElementById('sessions-list');
@@ -232,38 +272,7 @@
232
272
  return;
233
273
  }
234
274
 
235
- let html = '';
236
- sessions.forEach(s => {
237
- const workingDirectory = s.workingDirectory || 'Unknown directory';
238
- const encodedName = encodePathValue(s.name);
239
- const encodedDir = encodePathValue(workingDirectory);
240
- const timedInputCount = Number(s.timedInputCount) || 0;
241
- const timerBadge = timedInputCount > 0
242
- ? `<span class="timer-count-badge" title="${timedInputCount} scheduled timer${timedInputCount > 1 ? 's' : ''}">
243
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
244
- ${timedInputCount}
245
- </span>`
246
- : '';
247
- html += `<div class="session-card${s.id === activeSessionId ? ' selected' : ''}" data-session-id="${escapeHtml(s.id)}">
248
- <div class="session-info">
249
- <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>
250
- <p>${escapeHtml(s.tool)}</p>
251
- <p>${new Date(s.startTime).toLocaleTimeString()}</p>
252
- </div>
253
- <div class="session-actions">
254
- ${renderServerChanSessionAction(s)}
255
- <button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
256
- <button type="button" class="icon-btn btn-delete session-delete-btn" title="Delete session" aria-label="Delete session" onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg></button>
257
- </div>
258
- <div class="session-dir-row">
259
- <button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
260
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
261
- </button>
262
- <p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
263
- </div>
264
- </div>`;
265
- });
266
- list.innerHTML = html;
275
+ list.innerHTML = sessions.map(renderSessionCard).join('');
267
276
  } catch (e) {
268
277
  list.innerHTML = `<div style="color:#ff3b30; text-align:center; margin-top:50px;"><p>Failed to load: ${e.message}</p><button class="btn-retry" onclick="loadSessions()">Retry</button></div>`;
269
278
  }
@@ -284,13 +293,19 @@
284
293
  }
285
294
  }
286
295
 
287
- async function showToolModal() {
296
+ async function showToolModal(skill = null) {
297
+ window.pendingSkillHubSkill = skill || null;
288
298
  document.getElementById('modal-overlay').style.display = 'flex';
289
299
  const list = document.getElementById('tools-list');
300
+ const title = document.getElementById('tool-modal-title');
301
+ const skillLabel = document.getElementById('tool-modal-skill');
302
+ if (title) title.textContent = skill ? 'Start Skill Session' : 'Create Session';
303
+ if (skillLabel) skillLabel.textContent = skill ? String(skill.displayName || skill.name || '') : '';
290
304
  loadAppConfig();
291
305
  try {
292
306
  const res = await fetchWithTimeout('/api/tools');
293
- const tools = await res.json();
307
+ let tools = await res.json();
308
+ if (skill) tools = tools.filter(tool => tool.key === 'codex');
294
309
 
295
310
  let html = '';
296
311
  tools.forEach(t => {
@@ -325,7 +340,10 @@
325
340
  }
326
341
 
327
342
  function closeToolModal(e) {
328
- if (e.target.id === 'modal-overlay') document.getElementById('modal-overlay').style.display = 'none';
343
+ if (!e || e.target.id === 'modal-overlay') {
344
+ document.getElementById('modal-overlay').style.display = 'none';
345
+ window.pendingSkillHubSkill = null;
346
+ }
329
347
  }
330
348
 
331
349
  function isLobbyVisible() {