glad-web 1.0.45 → 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.
package/lib/web/core.js CHANGED
@@ -226,6 +226,40 @@
226
226
  }
227
227
  }
228
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
+
229
263
  async function loadSessions() {
230
264
  log('Loading sessions...');
231
265
  const list = document.getElementById('sessions-list');
@@ -238,38 +272,7 @@
238
272
  return;
239
273
  }
240
274
 
241
- let html = '';
242
- sessions.forEach(s => {
243
- const workingDirectory = s.workingDirectory || 'Unknown directory';
244
- const encodedName = encodePathValue(s.name);
245
- const encodedDir = encodePathValue(workingDirectory);
246
- const timedInputCount = Number(s.timedInputCount) || 0;
247
- const timerBadge = timedInputCount > 0
248
- ? `<span class="timer-count-badge" title="${timedInputCount} scheduled timer${timedInputCount > 1 ? 's' : ''}">
249
- <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>
250
- ${timedInputCount}
251
- </span>`
252
- : '';
253
- html += `<div class="session-card${s.id === activeSessionId ? ' selected' : ''}" data-session-id="${escapeHtml(s.id)}">
254
- <div class="session-info">
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>
256
- <p>${escapeHtml(s.tool)}</p>
257
- <p>${new Date(s.startTime).toLocaleTimeString()}</p>
258
- </div>
259
- <div class="session-actions">
260
- ${renderServerChanSessionAction(s)}
261
- <button class="btn-join" onclick="joinSession('${s.id}', decodePathValue('${encodedName}'), '${escapeHtml(s.toolKey || '')}')">Connect</button>
262
- <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>
263
- </div>
264
- <div class="session-dir-row">
265
- <button class="copy-dir-btn" title="Copy directory" onclick="copySessionDirectory(decodePathValue('${encodedDir}'), event)">
266
- <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>
267
- </button>
268
- <p title="${escapeHtml(workingDirectory)}">${escapeHtml(workingDirectory)}</p>
269
- </div>
270
- </div>`;
271
- });
272
- list.innerHTML = html;
275
+ list.innerHTML = sessions.map(renderSessionCard).join('');
273
276
  } catch (e) {
274
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>`;
275
278
  }
@@ -290,13 +293,19 @@
290
293
  }
291
294
  }
292
295
 
293
- async function showToolModal() {
296
+ async function showToolModal(skill = null) {
297
+ window.pendingSkillHubSkill = skill || null;
294
298
  document.getElementById('modal-overlay').style.display = 'flex';
295
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 || '') : '';
296
304
  loadAppConfig();
297
305
  try {
298
306
  const res = await fetchWithTimeout('/api/tools');
299
- const tools = await res.json();
307
+ let tools = await res.json();
308
+ if (skill) tools = tools.filter(tool => tool.key === 'codex');
300
309
 
301
310
  let html = '';
302
311
  tools.forEach(t => {
@@ -331,7 +340,10 @@
331
340
  }
332
341
 
333
342
  function closeToolModal(e) {
334
- if (!e || 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
+ }
335
347
  }
336
348
 
337
349
  function isLobbyVisible() {
@@ -6,16 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
7
7
  <meta name="apple-mobile-web-app-capable" content="yes">
8
8
  <meta name="theme-color" content="#000000">
9
- <script>
10
- (() => {
11
- const storedTheme = localStorage.getItem('glad-theme');
12
- const theme = storedTheme === 'light' || storedTheme === 'dark'
13
- ? storedTheme
14
- : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
15
- document.documentElement.dataset.theme = theme;
16
- document.documentElement.style.backgroundColor = theme === 'dark' ? '#000000' : '#f5f6f8';
17
- })();
18
- </script>
9
+ <script src="bootstrap.js"></script>
19
10
  <link rel="manifest" href="manifest.json">
20
11
  <link rel="icon" type="image/svg+xml" href="logo.svg">
21
12
  <link rel="apple-touch-icon" href="logo.svg">
@@ -50,7 +41,10 @@
50
41
  <div id="lobby-view" class="view active">
51
42
  <div id="lobby">
52
43
  <div class="header">
53
- <h1><img class="header-logo" src="logo.svg" alt="">Glad</h1>
44
+ <button id="skill-hall-button" class="skill-hall-entry-btn" type="button" disabled
45
+ onclick="openSkillHall()" title="Skill暂不可用" aria-label="Skill暂不可用">
46
+ <svg class="action-icon" aria-hidden="true"><use href="#icon-skills"></use></svg><span>Skills</span>
47
+ </button>
54
48
  <div class="header-actions">
55
49
  <button class="header-action-btn" onclick="showToolModal()" title="New AI session"><span>+</span><span>Session</span></button>
56
50
  <button id="usage-dashboard-button" class="header-action-btn icon-only" type="button"
@@ -359,7 +353,7 @@
359
353
  <!-- Tool Modal -->
360
354
  <div id="modal-overlay" onclick="closeToolModal(event)">
361
355
  <div id="tool-modal" onclick="event.stopPropagation()">
362
- <div class="tool-modal-header"><h2>Create Session</h2><button class="icon-btn" type="button" onclick="closeToolModal()" aria-label="Close">×</button></div>
356
+ <div class="tool-modal-header"><div><h2 id="tool-modal-title">Create Session</h2><p id="tool-modal-skill" class="tool-modal-skill"></p></div><button class="icon-btn" type="button" onclick="closeToolModal()" aria-label="Close">×</button></div>
363
357
  <div style="margin-bottom: 15px;">
364
358
  <label style="display:block; margin-bottom: 8px; font-size: 14px; color: var(--text-dim);">Working Directory (Optional):</label>
365
359
  <div style="margin-bottom: 8px; color: var(--text-dim); font-size: 12px; line-height: 1.4;">
@@ -373,6 +367,19 @@
373
367
  </div>
374
368
  </div>
375
369
 
370
+ <!-- Skill Hall Modal -->
371
+ <div id="skill-hall-overlay" onclick="closeSkillHall(event)">
372
+ <div id="skill-hall-modal" role="dialog" aria-modal="true" aria-labelledby="skill-hall-title" onclick="event.stopPropagation()">
373
+ <div class="skill-hall-header">
374
+ <div><h2 id="skill-hall-title">Skill Hall</h2><p>Select a private Skill to start a temporary Codex session.</p></div>
375
+ <button class="icon-btn" type="button" onclick="closeSkillHall()" aria-label="Close">×</button>
376
+ </div>
377
+ <input id="skill-hall-search" class="skill-hall-search" type="search" placeholder="Search skills…" aria-label="Search skills" oninput="filterSkillHall(this.value)">
378
+ <div id="skill-hall-status" class="skill-hall-status" aria-live="polite"></div>
379
+ <div id="skill-hall-grid" class="skill-hall-grid"></div>
380
+ </div>
381
+ </div>
382
+
376
383
  <!-- Usage Source Modal -->
377
384
  <div id="usage-source-overlay" onclick="closeUsageSourceModal(event)">
378
385
  <div id="usage-source-modal" role="dialog" aria-modal="true" aria-labelledby="usage-source-modal-title" onclick="event.stopPropagation()">
@@ -440,6 +447,29 @@
440
447
  <button id="serverchan-test-btn" class="small-btn" type="button" onclick="testServerChanSettings()">Send Test</button>
441
448
  </div>
442
449
  </section>
450
+ <section class="settings-section" aria-labelledby="skillhub-settings-title">
451
+ <div class="settings-section-label" id="skillhub-settings-title">Skills</div>
452
+ <div class="settings-provider-header">
453
+ <div><h3>SkillHub</h3><p>Connect Glad to a private Skill server.</p></div>
454
+ </div>
455
+ <div class="form-field">
456
+ <label for="skillhub-base-url">Server URL</label>
457
+ <input id="skillhub-base-url" type="url" autocomplete="off" placeholder="http://skillhub:10070">
458
+ <div class="serverchan-help">Remote servers must use HTTPS.</div>
459
+ </div>
460
+ <div class="form-field">
461
+ <label for="skillhub-token">API Token</label>
462
+ <input id="skillhub-token" type="password" autocomplete="off" placeholder="clh_...">
463
+ <div id="skillhub-token-hint" class="serverchan-help"></div>
464
+ </div>
465
+ <div id="skillhub-settings-status" class="serverchan-settings-status" aria-live="polite"></div>
466
+ <div class="serverchan-modal-actions">
467
+ <button id="skillhub-remove-btn" class="small-btn danger" type="button" onclick="removeSkillHubSettings()">Remove</button>
468
+ <span class="serverchan-action-spacer"></span>
469
+ <button id="skillhub-save-btn" class="small-btn primary" type="button" onclick="saveSkillHubSettings()">Save</button>
470
+ <button id="skillhub-test-btn" class="small-btn" type="button" onclick="testSkillHubSettings()">Test</button>
471
+ </div>
472
+ </section>
443
473
  </div>
444
474
  </div>
445
475
  <div id="app-toast" role="status" aria-live="polite"></div>
@@ -502,6 +532,7 @@
502
532
  <script src="core.js"></script>
503
533
  <script src="layout.js"></script>
504
534
  <script src="notifications.js"></script>
535
+ <script src="skillhub.js"></script>
505
536
  <script src="claude.js"></script>
506
537
  <script src="schedules.js"></script>
507
538
  <script src="shell.js"></script>
package/lib/web/layout.js CHANGED
@@ -1,19 +1,16 @@
1
- const GLAD_SPLIT_QUERY = '(min-width: 920px)';
2
- const GLAD_SIDEBAR_KEY = 'glad-sidebar-width';
1
+ const GLAD_SPLIT_QUERY = window.gladLayout.splitQuery;
2
+ const GLAD_SIDEBAR_KEY = window.gladLayout.sidebarStorageKey;
3
3
 
4
4
  function isSplitLayout() {
5
5
  return window.matchMedia(GLAD_SPLIT_QUERY).matches;
6
6
  }
7
7
 
8
8
  function clampSidebarWidth(value) {
9
- const max = Math.max(300, Math.min(480, window.innerWidth - 484));
10
- return Math.min(max, Math.max(300, Number(value) || 348));
9
+ return window.gladLayout.clampSidebarWidth(value);
11
10
  }
12
11
 
13
12
  function applySidebarWidth(value) {
14
- const width = clampSidebarWidth(value);
15
- document.documentElement.style.setProperty('--sidebar-w', `${width}px`);
16
- return width;
13
+ return window.gladLayout.applySidebarWidth(value);
17
14
  }
18
15
 
19
16
  function initializeResponsiveLayout() {
@@ -50,6 +50,7 @@
50
50
  event?.stopPropagation();
51
51
  document.getElementById('settings-modal-overlay').style.display = 'flex';
52
52
  if (typeof syncGladThemeControls === 'function') syncGladThemeControls();
53
+ if (typeof loadSkillHubSettings === 'function') void loadSkillHubSettings();
53
54
  setServerChanStatus('Loading configuration…');
54
55
  try {
55
56
  const response = await fetchWithTimeout('/api/notifications/serverchan', {}, 10000);
@@ -6,6 +6,7 @@
6
6
  activeSessionId = id;
7
7
  window.activeSessionId = id;
8
8
  activeToolKey = toolKey;
9
+ if (typeof resetComposerSendState === 'function') resetComposerSendState();
9
10
  clearTimeout(sessionPollTimer);
10
11
  sessionPollTimer = null;
11
12
  markCompletionRead(id);
@@ -108,7 +109,7 @@
108
109
  status: claudeStatus,
109
110
  pendingPermissionCount: claudePendingPermissions.filter(item => item.status === 'pending').length,
110
111
  canAbort: claudeStatus === 'thinking'
111
- });
112
+ }, { providerStateReceived: true });
112
113
  renderClaudeChat();
113
114
  }
114
115
  if (msg.type === 'claude-event') applyClaudeEvent(msg.event);
@@ -159,7 +160,7 @@
159
160
  if (msg.type === 'codex-snapshot' && msg.snapshot) {
160
161
  codexMessages = msg.snapshot.messages || [];
161
162
  codexPendingPermissions = msg.snapshot.pendingPermissions || [];
162
- applyCodexState(msg.snapshot.state || {});
163
+ applyCodexState(msg.snapshot.state || {}, { providerStateReceived: true });
163
164
  }
164
165
  if (msg.type === 'codex-detail-response' && msg.detail) applyCodexDetailResponse(msg);
165
166
  if (msg.type === 'codex-event') applyCodexEvent(msg.event);
package/lib/web/shell.js CHANGED
@@ -1,6 +1,20 @@
1
1
  async function deleteSession(id, e) {
2
- e.stopPropagation(); if (!confirm('Terminate session?')) return;
3
- try { await fetchWithTimeout('/api/sessions/' + id, { method: 'DELETE' }); refreshSessionsNow(); } catch (e) { alert('Failed to delete'); }
2
+ e?.stopPropagation();
3
+ if (!confirm('Terminate session?')) return;
4
+ const button = e?.currentTarget;
5
+ if (button) button.disabled = true;
6
+ try {
7
+ const response = await fetchWithTimeout('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }, 10000);
8
+ if (!response.ok) {
9
+ const data = await response.json().catch(() => ({}));
10
+ throw new Error(data.error || `HTTP ${response.status}`);
11
+ }
12
+ if (id === activeSessionId) showLobby();
13
+ else await refreshSessionsNow();
14
+ } catch (error) {
15
+ if (button?.isConnected) button.disabled = false;
16
+ alert(`Failed to delete session: ${error.message}`);
17
+ }
4
18
  }
5
19
 
6
20
  function showLobby() {
@@ -10,6 +24,7 @@
10
24
  activeSessionId = null;
11
25
  window.activeSessionId = null;
12
26
  activeToolKey = null;
27
+ if (typeof resetComposerSendState === 'function') resetComposerSendState();
13
28
  document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
14
29
  document.getElementById('lobby-view').classList.add('active');
15
30
  refreshSessionsNow();
@@ -0,0 +1,197 @@
1
+ let skillHubSettings = null;
2
+ let skillHallSkills = [];
3
+ let skillHallQuery = '';
4
+
5
+ function applySkillHubAvailability(available) {
6
+ const button = document.getElementById('skill-hall-button');
7
+ if (!button) return;
8
+ button.disabled = !available;
9
+ button.title = available ? 'Skill Hall' : 'Skill暂不可用';
10
+ button.setAttribute('aria-label', button.title);
11
+ }
12
+
13
+ async function refreshSkillHubAvailability() {
14
+ try {
15
+ const response = await fetchWithTimeout('/api/skillhub/status', {}, 5000);
16
+ const data = await response.json();
17
+ applySkillHubAvailability(Boolean(response.ok && data.available));
18
+ } catch (_) {
19
+ applySkillHubAvailability(false);
20
+ }
21
+ }
22
+
23
+ function setSkillHubStatus(message, type = '') {
24
+ const target = document.getElementById('skillhub-settings-status');
25
+ if (!target) return;
26
+ target.textContent = message;
27
+ target.className = `serverchan-settings-status${type ? ` ${type}` : ''}`;
28
+ }
29
+
30
+ function setSkillHubBusy(busy) {
31
+ for (const id of ['skillhub-save-btn', 'skillhub-test-btn', 'skillhub-remove-btn']) {
32
+ const button = document.getElementById(id);
33
+ if (button) button.disabled = busy;
34
+ }
35
+ }
36
+
37
+ function currentSkillHubForm() {
38
+ return {
39
+ baseUrl: document.getElementById('skillhub-base-url').value.trim(),
40
+ token: document.getElementById('skillhub-token').value.trim()
41
+ };
42
+ }
43
+
44
+ async function loadSkillHubSettings() {
45
+ setSkillHubStatus('Loading configuration…');
46
+ try {
47
+ const response = await fetchWithTimeout('/api/skillhub/settings', {}, 10000);
48
+ const data = await response.json();
49
+ if (!response.ok) throw new Error(data.error || 'Could not load SkillHub configuration');
50
+ skillHubSettings = data;
51
+ document.getElementById('skillhub-base-url').value = data.baseUrl || '';
52
+ const token = document.getElementById('skillhub-token');
53
+ token.value = '';
54
+ token.placeholder = data.configured ? data.maskedToken : 'clh_...';
55
+ document.getElementById('skillhub-token-hint').textContent = data.configured
56
+ ? `Saved: ${data.maskedToken}. Leave blank to keep the current Token.`
57
+ : 'The Token is encrypted on this Glad host.';
58
+ document.getElementById('skillhub-remove-btn').style.display = data.configured ? 'inline-flex' : 'none';
59
+ setSkillHubStatus(data.configured ? 'Configuration saved.' : 'SkillHub is not configured.');
60
+ } catch (error) {
61
+ setSkillHubStatus(error.message || 'Could not load SkillHub configuration', 'error');
62
+ }
63
+ }
64
+
65
+ async function saveSkillHubSettings() {
66
+ setSkillHubBusy(true);
67
+ setSkillHubStatus('Testing and saving…');
68
+ try {
69
+ const response = await fetchWithTimeout('/api/skillhub/settings', {
70
+ method: 'PUT',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify(currentSkillHubForm())
73
+ }, 20000);
74
+ const data = await response.json();
75
+ if (!response.ok) throw new Error(data.error || 'Could not save SkillHub configuration');
76
+ skillHubSettings = data.settings;
77
+ const token = document.getElementById('skillhub-token');
78
+ token.value = '';
79
+ token.placeholder = data.settings.maskedToken;
80
+ document.getElementById('skillhub-token-hint').textContent =
81
+ `Saved: ${data.settings.maskedToken}. Leave blank to keep the current Token.`;
82
+ document.getElementById('skillhub-remove-btn').style.display = 'inline-flex';
83
+ const handle = data.user?.handle || data.user?.data?.handle || '';
84
+ setSkillHubStatus(`Connected${handle ? ` as ${handle}` : ''}.`, 'success');
85
+ } catch (error) {
86
+ setSkillHubStatus(error.message || 'Could not save SkillHub configuration', 'error');
87
+ } finally {
88
+ setSkillHubBusy(false);
89
+ }
90
+ }
91
+
92
+ async function testSkillHubSettings() {
93
+ setSkillHubBusy(true);
94
+ setSkillHubStatus('Testing connection…');
95
+ try {
96
+ const response = await fetchWithTimeout('/api/skillhub/settings/test', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify(currentSkillHubForm())
100
+ }, 20000);
101
+ const data = await response.json();
102
+ if (!response.ok) throw new Error(data.error || 'SkillHub connection failed');
103
+ const handle = data.user?.handle || data.user?.data?.handle || '';
104
+ setSkillHubStatus(`Connection successful${handle ? ` · ${handle}` : ''}.`, 'success');
105
+ } catch (error) {
106
+ setSkillHubStatus(error.message || 'SkillHub connection failed', 'error');
107
+ } finally {
108
+ setSkillHubBusy(false);
109
+ }
110
+ }
111
+
112
+ async function removeSkillHubSettings() {
113
+ if (!confirm('Remove the SkillHub connection?')) return;
114
+ setSkillHubBusy(true);
115
+ try {
116
+ const response = await fetchWithTimeout('/api/skillhub/settings', { method: 'DELETE' }, 10000);
117
+ const data = await response.json();
118
+ if (!response.ok) throw new Error(data.error || 'Could not remove SkillHub configuration');
119
+ skillHubSettings = data.settings;
120
+ document.getElementById('skillhub-base-url').value = '';
121
+ document.getElementById('skillhub-token').value = '';
122
+ document.getElementById('skillhub-token').placeholder = 'clh_...';
123
+ document.getElementById('skillhub-token-hint').textContent = 'The Token is encrypted on this Glad host.';
124
+ document.getElementById('skillhub-remove-btn').style.display = 'none';
125
+ setSkillHubStatus('SkillHub configuration removed.', 'success');
126
+ } catch (error) {
127
+ setSkillHubStatus(error.message || 'Could not remove SkillHub configuration', 'error');
128
+ } finally {
129
+ setSkillHubBusy(false);
130
+ }
131
+ }
132
+
133
+ function skillHallCard(skill, index) {
134
+ const name = String(skill.displayName || skill.name || skill.slug || 'Unnamed Skill');
135
+ const description = String(skill.description || '');
136
+ const meta = [skill.kind, skill.version].filter(Boolean).join(' · ');
137
+ return `<button type="button" class="skill-hall-card" onclick="selectSkillHallSkill(${index})"><span class="skill-hall-card-name">${escapeHtml(name)}</span>${description ? `<span class="skill-hall-card-description">${escapeHtml(description)}</span>` : ''}<span class="skill-hall-card-meta">${escapeHtml(meta)}</span></button>`;
138
+ }
139
+
140
+ function renderSkillHall() {
141
+ const grid = document.getElementById('skill-hall-grid');
142
+ const status = document.getElementById('skill-hall-status');
143
+ if (!grid || !status) return;
144
+ const query = skillHallQuery.trim().toLocaleLowerCase();
145
+ const matches = skillHallSkills.map((skill, index) => ({ skill, index })).filter(({ skill }) => {
146
+ if (!query) return true;
147
+ return [skill.displayName, skill.name, skill.slug, skill.description, skill.kind, skill.version]
148
+ .some(value => String(value || '').toLocaleLowerCase().includes(query));
149
+ });
150
+ status.textContent = matches.length ? `${matches.length} Skill${matches.length === 1 ? '' : 's'}`
151
+ : (query ? 'No matching Skills.' : 'No Skills are available for this account.');
152
+ grid.innerHTML = matches.map(({ skill, index }) => skillHallCard(skill, index)).join('');
153
+ }
154
+
155
+ async function openSkillHall(event = null) {
156
+ event?.stopPropagation();
157
+ if (document.getElementById('skill-hall-button')?.disabled) {
158
+ showAppToast('Skill暂不可用');
159
+ return;
160
+ }
161
+ document.getElementById('skill-hall-overlay').style.display = 'flex';
162
+ document.getElementById('skill-hall-search').value = '';
163
+ document.getElementById('skill-hall-status').textContent = 'Loading Skills…';
164
+ document.getElementById('skill-hall-grid').innerHTML = '';
165
+ skillHallQuery = '';
166
+ try {
167
+ const response = await fetchWithTimeout('/api/skillhub/skills', {}, 30000);
168
+ const data = await response.json();
169
+ if (!response.ok || !data.success) throw new Error(data.error || 'Could not load Skills');
170
+ skillHallSkills = Array.isArray(data.skills) ? data.skills : [];
171
+ renderSkillHall();
172
+ requestAnimationFrame(() => document.getElementById('skill-hall-search')?.focus());
173
+ } catch (error) {
174
+ skillHallSkills = [];
175
+ document.getElementById('skill-hall-status').textContent = error.message || 'Could not load Skills';
176
+ if (error.message === 'Skill暂不可用') applySkillHubAvailability(false);
177
+ }
178
+ }
179
+
180
+ function closeSkillHall(event = null) {
181
+ if (event && event.target.id !== 'skill-hall-overlay') return;
182
+ document.getElementById('skill-hall-overlay').style.display = 'none';
183
+ }
184
+
185
+ function filterSkillHall(value) {
186
+ skillHallQuery = String(value || '');
187
+ renderSkillHall();
188
+ }
189
+
190
+ function selectSkillHallSkill(index) {
191
+ const skill = skillHallSkills[index];
192
+ if (!skill) return;
193
+ closeSkillHall();
194
+ showToolModal(skill);
195
+ }
196
+
197
+ void refreshSkillHubAvailability();
@@ -4,17 +4,20 @@
4
4
  .view.active { display: flex; }
5
5
  #lobby { overflow-y: auto; padding: 20px; padding-top: env(safe-area-inset-top); box-sizing: border-box; }
6
6
  .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
7
- .header h1 { font-size: 28px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 10px; }
8
- .header-logo { width: 32px; height: 32px; flex-shrink: 0; }
9
- .header-actions { display: flex; align-items: center; gap: 5px; }
7
+ .header-actions { display: flex; align-items: center; gap: 5px; margin-left: auto; }
8
+ .skill-hall-entry-btn { height: 36px; padding: 0 11px; border: 1px solid rgba(0,122,255,.38); border-radius: 10px; background: rgba(0,122,255,.12); color: var(--primary); display: inline-flex; align-items: center; justify-content: center; gap: 5px; font-size: 13px; font-weight: 800; cursor: pointer; flex-shrink: 0; }
9
+ .skill-hall-entry-btn:active { background: rgba(0,122,255,.22); transform: scale(.97); }
10
+ .skill-hall-entry-btn:disabled { border-color: rgba(142,142,147,.25); background: rgba(142,142,147,.1); color: var(--text-dim); cursor: not-allowed; transform: none; }
10
11
  .header-action-btn { height: 36px; min-width: 36px; padding: 0 10px; border: 0; border-radius: 10px; background: var(--primary); color: #fff; display: inline-flex; align-items: center; justify-content: center; gap: 3px; font-size: 13px; font-weight: 750; line-height: 1; white-space: nowrap; cursor: pointer; flex-shrink: 0; }
11
12
  .header-action-btn.icon-only { width: 36px; padding: 0; }
12
13
  .header-action-btn:active { background: #0062cc; transform: scale(.97); }
13
14
  .btn-retry { background: #333; color: #fff; border: none; padding: 8px 16px; border-radius: 20px; margin-top: 10px; cursor: pointer; }
14
15
  .session-card { background: var(--card-bg); border-radius: 12px; padding: 12px 16px 8px; margin-bottom: 12px; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 10px; row-gap: 0; align-items: center; position: relative; }
16
+ .active-session-dot { display: none; }
15
17
  .completion-dot { width: 9px; height: 9px; border-radius: 50%; background: #ff3b30; flex-shrink: 0; }
16
18
  .session-info { flex: 1; min-width: 0; }
17
- .session-info h3 { margin: 0 0 4px 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
19
+ .session-info h3 { min-width: 0; margin: 0 0 4px 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
20
+ .session-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
18
21
  .session-info p { margin: 0; font-size: 13px; color: var(--text-dim); }
19
22
  .timer-count-badge { display: inline-flex; align-items: center; gap: 3px; color: #fff; background: rgba(0,122,255,0.22); border: 1px solid rgba(0,122,255,0.34); border-radius: 999px; padding: 2px 7px; font-size: 11px; font-weight: 800; flex-shrink: 0; }
20
23
  .session-dir-row { grid-column: 1 / -1; display: flex; align-items: center; gap: 6px; width: 100%; min-width: 0; }
@@ -37,7 +40,7 @@
37
40
  #modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 10000; display: none; align-items: center; justify-content: center; padding: 20px; }
38
41
  #tool-modal { background: var(--card-bg); width: 100%; max-width: 400px; border-radius: 16px; padding: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); }
39
42
  #settings-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 10020; display: none; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; }
40
- #settings-modal { background: var(--card-bg); width: 100%; max-width: 450px; border-radius: 16px; padding: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); box-sizing: border-box; }
43
+ #settings-modal { background: var(--card-bg); width: 100%; max-width: 450px; max-height: calc(100vh - 40px); overflow-y: auto; border-radius: 16px; padding: 20px; box-shadow: 0 20px 40px rgba(0,0,0,0.4); box-sizing: border-box; }
41
44
  .settings-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 20px; }
42
45
  .settings-modal-header h2 { margin: 0; font-size: 20px; }
43
46
  .settings-modal-header p { margin: 5px 0 0; color: var(--text-dim); font-size: 12px; line-height: 1.45; }
@@ -58,6 +61,22 @@
58
61
  .serverchan-modal-actions { display: flex; align-items: center; gap: 8px; }
59
62
  .serverchan-action-spacer { flex: 1; }
60
63
  #serverchan-remove-btn { display: none; }
64
+ #skillhub-remove-btn { display: none; }
65
+ .tool-modal-skill { min-height: 16px; margin: 4px 0 0; color: var(--text-dim); font-size: 12px; }
66
+ #skill-hall-overlay { position: fixed; inset: 0; z-index: 10010; display: none; align-items: center; justify-content: center; padding: 18px; box-sizing: border-box; background: rgba(0,0,0,0.8); }
67
+ #skill-hall-modal { display: flex; flex-direction: column; width: min(820px, 100%); max-height: min(760px, calc(100vh - 36px)); padding: 20px; box-sizing: border-box; border-radius: 16px; background: var(--card-bg); box-shadow: 0 20px 50px rgba(0,0,0,0.45); }
68
+ .skill-hall-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
69
+ .skill-hall-header h2 { margin: 0; font-size: 21px; }
70
+ .skill-hall-header p { margin: 5px 0 0; color: var(--text-dim); font-size: 12px; }
71
+ .skill-hall-search { width: 100%; min-height: 42px; margin-top: 16px; padding: 9px 11px; box-sizing: border-box; border: 1px solid rgba(255,255,255,0.12); border-radius: 9px; background: rgba(255,255,255,0.08); color: var(--text); font-size: 14px; outline: none; }
72
+ .skill-hall-search:focus { border-color: rgba(0,122,255,0.65); }
73
+ .skill-hall-status { min-height: 20px; padding: 10px 1px 7px; color: var(--text-dim); font-size: 12px; }
74
+ .skill-hall-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; overflow-y: auto; padding: 1px; }
75
+ .skill-hall-card { display: flex; min-height: 126px; flex-direction: column; align-items: stretch; gap: 8px; padding: 14px; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; background: rgba(255,255,255,0.045); color: var(--text); text-align: left; cursor: pointer; }
76
+ .skill-hall-card:hover { border-color: rgba(0,122,255,0.55); background: rgba(0,122,255,0.09); }
77
+ .skill-hall-card-name { font-size: 15px; font-weight: 700; overflow-wrap: anywhere; }
78
+ .skill-hall-card-description { display: -webkit-box; overflow: hidden; color: var(--text-dim); font-size: 12px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
79
+ .skill-hall-card-meta { margin-top: auto; color: var(--primary); font-size: 11px; }
61
80
  #app-toast { position: fixed; left: 50%; bottom: max(24px, env(safe-area-inset-bottom)); z-index: 10100; max-width: calc(100vw - 32px); transform: translate(-50%, 18px); opacity: 0; pointer-events: none; padding: 9px 14px; border-radius: 999px; background: rgba(44,44,46,0.96); border: 1px solid rgba(255,255,255,0.12); color: #fff; font-size: 13px; box-shadow: 0 10px 28px rgba(0,0,0,0.35); transition: opacity .18s ease, transform .18s ease; }
62
81
  #app-toast.visible { opacity: 1; transform: translate(-50%, 0); }
63
82
  .tool-item { padding: 12px; border-bottom: 1px solid #333; cursor: pointer; display: flex; align-items: center; border-radius: 8px; margin-top: 4px; }
@@ -143,6 +162,7 @@
143
162
  #cmd-input::placeholder { color: rgba(255,255,255,0.45); }
144
163
  #cmd-input:focus { background: rgba(255,255,255,0.1); border-color: rgba(0,122,255,0.42); color: #fff; }
145
164
  #send-btn { width: 44px; height: 38px; margin-left: 10px; background: #007aff; border: none; border-radius: 19px; color: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; }
165
+ #send-btn:disabled { background: color-mix(in srgb, var(--text-dim) 34%, var(--surface)); color: var(--text-dim); opacity: .72; cursor: not-allowed; }
146
166
  #attachment-strip { display: none; width: min(calc(100% - 28px), var(--control-content-max)); margin: -3px auto 2px auto; gap: 7px; overflow-x: auto; padding: 0 0 4px 0; box-sizing: border-box; scrollbar-width: none; }
147
167
  #attachment-strip.active { display: flex; }
148
168
  #attachment-strip::-webkit-scrollbar { display: none; }
@@ -671,14 +691,16 @@ html[data-theme="light"] .session-attention-pill.subagent { border-color: #99c8f
671
691
  #lobby-view { display: flex !important; width: min(var(--sidebar-w), calc(100vw - 484px)); flex: 0 0 min(var(--sidebar-w), calc(100vw - 484px)); border-right: 1px solid var(--line); background: var(--bg); }
672
692
  #lobby { width: 100%; padding: 18px 14px; }
673
693
  #lobby-view .header { margin-bottom: 18px; }
674
- #lobby-view .header h1 { font-size: 23px; }
675
- #lobby-view .header-action-btn:not(.icon-only) { width: 36px; padding: 0; font-size: 0; }
676
- #lobby-view .header-action-btn:not(.icon-only) span:first-child { font-size: 20px; }
694
+ #lobby-view .header-actions { gap: 3px; }
695
+ #lobby-view .header-action-btn.icon-only { width: 34px; min-width: 34px; }
696
+ #lobby-view .header-action-btn:not(.icon-only) { width: auto; min-width: 0; padding: 0 8px; font-size: 13px; }
697
+ #lobby-view .header-action-btn:not(.icon-only) span:first-child { font-size: 18px; }
677
698
  #sidebar-resizer { display: block; width: 4px; flex: 0 0 4px; cursor: col-resize; background: var(--line); touch-action: none; transition: background .15s; }
678
699
  #sidebar-resizer:hover, #sidebar-resizer.dragging { background: var(--primary); }
679
700
  #detail-pane:not(:has(.view.active)) #detail-empty { display: flex; }
680
701
  #back-btn, .usage-nav-button { visibility: hidden; pointer-events: none; }
681
702
  .session-card.selected { border: 1px solid color-mix(in srgb, var(--primary) 48%, transparent); background: color-mix(in srgb, var(--primary) 10%, var(--card-bg)); }
703
+ .session-card.selected .active-session-dot { position: absolute; top: 7px; right: 7px; display: block; width: 9px; height: 9px; border: 2px solid var(--card-bg); border-radius: 50%; background: #34c759; box-shadow: 0 0 0 1px rgba(52,199,89,.25); pointer-events: none; }
682
704
  }
683
705
 
684
706
  @media (max-width: 919px) {
@@ -703,6 +725,7 @@ html[data-theme="light"] .usage-panel,
703
725
  html[data-theme="light"] .usage-summary-card,
704
726
  html[data-theme="light"] #tool-modal,
705
727
  html[data-theme="light"] #settings-modal,
728
+ html[data-theme="light"] #skill-hall-modal,
706
729
  html[data-theme="light"] #usage-source-modal,
707
730
  html[data-theme="light"] #schedule-modal,
708
731
  html[data-theme="light"] #claude-picker-panel,
@@ -715,6 +738,7 @@ html[data-theme="light"] #codex-prompt-panel,
715
738
  html[data-theme="light"] #codex-skill-panel { background: var(--card-bg) !important; border-color: var(--line) !important; color: var(--text) !important; }
716
739
  html[data-theme="light"] #modal-overlay,
717
740
  html[data-theme="light"] #settings-modal-overlay,
741
+ html[data-theme="light"] #skill-hall-overlay,
718
742
  html[data-theme="light"] #usage-source-overlay { background: var(--overlay-bg); }
719
743
  html[data-theme="light"] #claude-chat-container,
720
744
  html[data-theme="light"] #codex-chat-container,
@@ -741,11 +765,14 @@ html[data-theme="light"] .composer-action-btn,
741
765
  html[data-theme="light"] .key-btn,
742
766
  html[data-theme="light"] .command-key,
743
767
  html[data-theme="light"] .small-btn { border-color: var(--line); background: var(--surface-soft); color: var(--text); }
768
+ html[data-theme="light"] .skill-hall-card { border-color: var(--line); background: var(--surface-soft); color: var(--text); }
769
+ html[data-theme="light"] .skill-hall-entry-btn { border-color: rgba(0,122,255,.28); background: rgba(0,122,255,.08); }
744
770
  html[data-theme="light"] .claude-ctrl-btn.primary { color: #065fbd; background: rgba(0,122,255,.11); }
745
771
  html[data-theme="light"] .claude-ctrl-btn.danger,
746
772
  html[data-theme="light"] .small-btn.danger { color: #d92d20; background: rgba(217,45,32,.09); }
747
773
  html[data-theme="light"] #settings-modal input,
748
774
  html[data-theme="light"] #settings-modal select,
775
+ html[data-theme="light"] .skill-hall-search,
749
776
  html[data-theme="light"] .form-field input,
750
777
  html[data-theme="light"] .form-field select,
751
778
  html[data-theme="light"] .form-field textarea,