glad-web 1.0.44 → 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,24 +1,116 @@
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
102
  const timerOpen = document.getElementById('timed-send-panel').classList.contains('active');
13
103
  document.getElementById('schedule-send-btn').classList.toggle('active', timerOpen);
14
- document.getElementById('attach-image-btn').classList.toggle('active', selectedImageAttachments.length > 0);
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);
22
114
  syncComposerButtonState();
23
115
  updateTerminalControlsHeight();
24
116
  }
@@ -26,7 +118,7 @@
26
118
  window.removeImageAttachment = async function(attachmentId) {
27
119
  const attachment = selectedImageAttachments.find(item => item.id === attachmentId);
28
120
  selectedImageAttachments = selectedImageAttachments.filter(item => item.id !== attachmentId);
29
- renderImageAttachments();
121
+ renderComposerAttachments();
30
122
  if (!attachment) return;
31
123
  clearInterval(attachment.indicatorTimer);
32
124
  attachment.abortUpload?.();
@@ -38,24 +130,43 @@
38
130
  }
39
131
  };
40
132
 
41
- 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() {
42
146
  const pending = selectedImageAttachments;
147
+ const pendingFiles = selectedFileAttachments;
43
148
  selectedImageAttachments = [];
44
- renderImageAttachments();
149
+ selectedFileAttachments = [];
150
+ renderComposerAttachments();
45
151
  for (const item of pending) item.abortUpload?.();
152
+ for (const item of pendingFiles) item.abortUpload?.();
46
153
  await Promise.all(pending.filter(item => !item.uploading).map(item => fetchWithTimeout(
47
154
  `/api/sessions/${item.sessionId}/attachments/images/${encodeURIComponent(item.id)}`,
48
155
  { method: 'DELETE' }
49
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)));
50
161
  }
51
162
 
52
- const IMAGE_UPLOAD_CHUNK_BYTES = 512 * 1024;
163
+ const ATTACHMENT_UPLOAD_CHUNK_BYTES = 512 * 1024;
53
164
 
54
165
  function uploadImageInChunks(sessionId, file, onProgress) {
55
166
  let xhr = null;
56
167
  let cancelled = false;
57
168
  const uploadId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
58
- const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
169
+ const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
59
170
  return {
60
171
  abort: () => {
61
172
  cancelled = true;
@@ -65,8 +176,8 @@
65
176
  promise: (async () => {
66
177
  for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex++) {
67
178
  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));
179
+ const start = chunkIndex * ATTACHMENT_UPLOAD_CHUNK_BYTES;
180
+ const chunk = file.slice(start, Math.min(file.size, start + ATTACHMENT_UPLOAD_CHUNK_BYTES));
70
181
  const result = await new Promise((resolve, reject) => {
71
182
  xhr = new XMLHttpRequest();
72
183
  xhr.open('POST', `/api/sessions/${sessionId}/attachments/images/chunks`);
@@ -111,7 +222,7 @@
111
222
  alert(`${file.name} is larger than 50 MB.`);
112
223
  continue;
113
224
  }
114
- const chunkTotal = Math.ceil(file.size / IMAGE_UPLOAD_CHUNK_BYTES);
225
+ const chunkTotal = Math.ceil(file.size / ATTACHMENT_UPLOAD_CHUNK_BYTES);
115
226
  const pending = {
116
227
  id: `uploading-${crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`}`,
117
228
  name: file.name || 'image',
@@ -123,12 +234,12 @@
123
234
  abortUpload: null
124
235
  };
125
236
  selectedImageAttachments.push(pending);
126
- renderImageAttachments();
237
+ renderComposerAttachments();
127
238
  try {
128
239
  const upload = uploadImageInChunks(activeSessionId, file, progress => {
129
240
  pending.progress = progress;
130
241
  pending.status = `${progress}%`;
131
- renderImageAttachments();
242
+ renderComposerAttachments();
132
243
  });
133
244
  pending.abortUpload = upload.abort;
134
245
  const attachment = await upload.promise;
@@ -138,16 +249,121 @@
138
249
  continue;
139
250
  }
140
251
  selectedImageAttachments[index] = { ...attachment, name: file.name || attachment.name, sessionId: activeSessionId };
141
- renderImageAttachments();
252
+ renderComposerAttachments();
142
253
  } catch (e) {
143
254
  selectedImageAttachments = selectedImageAttachments.filter(item => item !== pending);
144
- renderImageAttachments();
255
+ renderComposerAttachments();
145
256
  if (e.message === 'Image upload cancelled') continue;
146
257
  alert(`Could not add ${file.name}: ${e.message}`);
147
258
  }
148
259
  }
149
260
  }
150
261
 
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);
365
+ }
366
+
151
367
  function openTimedSendPanel() {
152
368
  if (isClaudeSession()) {
153
369
  closeClaudePicker();
@@ -181,43 +397,62 @@
181
397
  function performSend() {
182
398
  const val = inputEl.value;
183
399
  const readyImageAttachments = selectedImageAttachments.filter(item => !item.uploading);
184
- if (selectedImageAttachments.some(item => item.uploading)) {
185
- 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.');
186
403
  return;
187
404
  }
188
- if ((val || readyImageAttachments.length) && isClaudeSession()) {
405
+ if ((val || readyImageAttachments.length || readyFileAttachments.length) && isClaudeSession()) {
189
406
  if (currentSocket && currentSocket.readyState === 1) {
190
407
  currentSocket.send(JSON.stringify({
191
408
  type: 'claude-input',
192
409
  text: val,
193
- attachmentIds: readyImageAttachments.map(item => item.id)
410
+ attachmentIds: readyImageAttachments.map(item => item.id),
411
+ ...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {})
194
412
  }));
195
413
  }
196
414
  inputEl.value = '';
197
415
  inputEl.style.height = '38px';
198
416
  selectedImageAttachments = [];
199
- renderImageAttachments();
417
+ selectedFileAttachments = [];
418
+ renderComposerAttachments();
200
419
  return;
201
420
  }
202
- if ((val || readyImageAttachments.length) && isCodexSession() && codexState.presentation === 'structured') {
421
+ if ((val || readyImageAttachments.length || readyFileAttachments.length) && isCodexSession()) {
203
422
  if (!codexReadyForInput()) return;
204
423
  if (currentSocket && currentSocket.readyState === 1) {
205
424
  currentSocket.send(JSON.stringify({
206
425
  type: 'codex-input',
207
426
  text: val,
208
427
  attachmentIds: readyImageAttachments.map(item => item.id),
428
+ ...(readyFileAttachments.length ? { fileAttachmentIds: readyFileAttachments.map(item => item.id) } : {}),
209
429
  skills: selectedCodexSkill ? [{ name: selectedCodexSkill.name, path: selectedCodexSkill.path }] : []
210
430
  }));
211
431
  }
212
432
  inputEl.value = '';
213
433
  inputEl.style.height = '38px';
214
434
  selectedImageAttachments = [];
435
+ selectedFileAttachments = [];
215
436
  selectedCodexSkill = null;
216
- renderImageAttachments();
437
+ renderComposerAttachments();
217
438
  renderCodexChat();
218
439
  return;
219
440
  }
220
- 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
+ }
221
456
  const formattedVal = val.replace(/\n/g, '\r');
222
457
  sendWS(formattedVal);
223
458
  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 = [];
@@ -325,7 +331,7 @@
325
331
  }
326
332
 
327
333
  function closeToolModal(e) {
328
- 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';
329
335
  }
330
336
 
331
337
  function isLobbyVisible() {