clideck 1.22.2

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/activity.js +56 -0
  4. package/agent-presets.json +93 -0
  5. package/assets/clideck-themes.jpg +0 -0
  6. package/bin/clideck.js +2 -0
  7. package/config.js +96 -0
  8. package/handlers.js +297 -0
  9. package/opencode-bridge.js +148 -0
  10. package/opencode-plugin/clideck-bridge.js +24 -0
  11. package/package.json +47 -0
  12. package/paths.js +41 -0
  13. package/plugin-loader.js +285 -0
  14. package/plugins/trim-clip/clideck-plugin.json +13 -0
  15. package/plugins/trim-clip/client.js +31 -0
  16. package/plugins/trim-clip/index.js +10 -0
  17. package/plugins/voice-input/clideck-plugin.json +49 -0
  18. package/plugins/voice-input/client.js +196 -0
  19. package/plugins/voice-input/index.js +342 -0
  20. package/plugins/voice-input/python/mel_filters.npz +0 -0
  21. package/plugins/voice-input/python/whisper_turbo.py +416 -0
  22. package/plugins/voice-input/python/worker.py +135 -0
  23. package/public/fx/bold-beep-idle.mp3 +0 -0
  24. package/public/fx/default-beep.mp3 +0 -0
  25. package/public/fx/echo-beep-idle.mp3 +0 -0
  26. package/public/fx/musical-beep-idle.mp3 +0 -0
  27. package/public/fx/small-bleep-idle.mp3 +0 -0
  28. package/public/fx/soft-beep.mp3 +0 -0
  29. package/public/fx/space-idle.mp3 +0 -0
  30. package/public/img/claude-code.png +0 -0
  31. package/public/img/clideck-logo-icon.png +0 -0
  32. package/public/img/clideck-logo-terminal-panel.png +0 -0
  33. package/public/img/codex.png +0 -0
  34. package/public/img/gemini.png +0 -0
  35. package/public/img/opencode.png +0 -0
  36. package/public/index.html +243 -0
  37. package/public/js/app.js +794 -0
  38. package/public/js/color-mode.js +51 -0
  39. package/public/js/confirm.js +27 -0
  40. package/public/js/creator.js +201 -0
  41. package/public/js/drag.js +134 -0
  42. package/public/js/folder-picker.js +81 -0
  43. package/public/js/hotkeys.js +90 -0
  44. package/public/js/nav.js +56 -0
  45. package/public/js/profiles.js +22 -0
  46. package/public/js/prompts.js +325 -0
  47. package/public/js/settings.js +489 -0
  48. package/public/js/state.js +15 -0
  49. package/public/js/terminals.js +905 -0
  50. package/public/js/toast.js +62 -0
  51. package/public/js/utils.js +27 -0
  52. package/public/tailwind.css +1 -0
  53. package/server.js +126 -0
  54. package/sessions.js +375 -0
  55. package/telemetry-receiver.js +129 -0
  56. package/themes.js +247 -0
  57. package/transcript.js +90 -0
  58. package/utils.js +66 -0
@@ -0,0 +1,905 @@
1
+ import { state, send } from './state.js';
2
+ import { esc } from './utils.js';
3
+ import { resolveTheme, resolveAccent, applyTheme } from './profiles.js';
4
+ import { attachToTerminal } from './hotkeys.js';
5
+ import { closeDropdown } from './prompts.js';
6
+ function isLightBg(themeId) {
7
+ const bg = resolveTheme(themeId)?.background;
8
+ if (!bg || bg[0] !== '#') return false;
9
+ const r = parseInt(bg.slice(1, 3), 16), g = parseInt(bg.slice(3, 5), 16), b = parseInt(bg.slice(5, 7), 16);
10
+ return (r * 299 + g * 587 + b * 114) / 1000 > 150;
11
+ }
12
+
13
+ // --- Helpers ---
14
+
15
+ const RECENT_MS = 15 * 60 * 1000; // 15 minutes
16
+
17
+ function isRecent(ts) { return Date.now() - ts < RECENT_MS; }
18
+
19
+ function formatTime(ts) {
20
+ const d = new Date(ts), now = new Date();
21
+ const days = Math.floor((now - d) / 86400000);
22
+ if (days === 0 && d.getDate() === now.getDate())
23
+ return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
24
+ if (days < 2 && (now.getDate() - d.getDate() === 1 || (now.getDate() < d.getDate() && days < 2)))
25
+ return 'Yesterday';
26
+ if (days < 7) return d.toLocaleDateString([], { weekday: 'long' });
27
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
28
+ }
29
+
30
+ function updateTimeEl(el, ts) {
31
+ el.textContent = formatTime(ts);
32
+ el.classList.toggle('recent', isRecent(ts));
33
+ }
34
+
35
+
36
+ const TERMINAL_SVG = `<svg class="w-3.5 h-3.5 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`;
37
+ const MIN_CONTRAST_RATIO = 4.5;
38
+
39
+ const DARK_BALLS = ['#00e5ff', '#5df0d6', '#9b8cff'];
40
+ const LIGHT_BALLS = ['#0891b2', '#059669', '#7c3aed'];
41
+
42
+ function startBounce(container) {
43
+ const isDark = !document.documentElement.classList.contains('light');
44
+ const colors = isDark ? DARK_BALLS : LIGHT_BALLS;
45
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
46
+ svg.setAttribute('width', '30');
47
+ svg.setAttribute('height', '14');
48
+ svg.setAttribute('viewBox', '5 18 90 28');
49
+ svg.style.opacity = '0.75';
50
+ container.innerHTML = '';
51
+ container.appendChild(svg);
52
+
53
+ const floor = 40, gravity = 0.18, restitution = 0.7;
54
+ const rand = (lo, hi) => lo + Math.random() * (hi - lo);
55
+ const radii = [4.5, 4, 3.5];
56
+
57
+ const balls = colors.map((fill, i) => {
58
+ const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
59
+ c.setAttribute('r', radii[i]);
60
+ c.setAttribute('fill', fill);
61
+ svg.appendChild(c);
62
+ return { el: c, x: 10 + i * rand(14, 22), y: floor - rand(0, 15), vx: rand(0.6, 1.3), vy: -rand(2.5, 5), r: radii[i] };
63
+ });
64
+
65
+ let raf;
66
+ function step() {
67
+ balls.forEach(b => {
68
+ b.vy += gravity;
69
+ b.x += b.vx;
70
+ b.y += b.vy;
71
+ if (b.y > floor) { b.y = floor; b.vy *= -restitution; }
72
+ });
73
+ for (let i = 0; i < balls.length; i++) {
74
+ for (let j = i + 1; j < balls.length; j++) {
75
+ const a = balls[i], b = balls[j];
76
+ const dx = b.x - a.x, dy = b.y - a.y;
77
+ const dist = Math.hypot(dx, dy), min = a.r + b.r;
78
+ if (dist < min) {
79
+ const nx = dx / dist, ny = dy / dist;
80
+ const p = a.vx * nx + a.vy * ny - b.vx * nx - b.vy * ny;
81
+ a.vx -= p * nx; a.vy -= p * ny;
82
+ b.vx += p * nx; b.vy += p * ny;
83
+ }
84
+ }
85
+ }
86
+ balls.forEach(b => {
87
+ if (b.x > 100) { b.x = rand(5, 15); b.y = floor - rand(0, 10); b.vx = rand(0.6, 1.3); b.vy = -rand(2.5, 5); }
88
+ b.el.setAttribute('cx', b.x);
89
+ b.el.setAttribute('cy', b.y);
90
+ });
91
+ raf = requestAnimationFrame(step);
92
+ }
93
+ step();
94
+ return () => cancelAnimationFrame(raf);
95
+ }
96
+
97
+ function iconHtml(commandId) {
98
+ const icon = state.cfg.commands.find(c => c.id === commandId)?.icon || 'terminal';
99
+ if (icon.startsWith('/'))
100
+ return `<img src="${esc(icon)}" class="w-5 h-5 object-contain" draggable="false">`;
101
+ return TERMINAL_SVG;
102
+ }
103
+
104
+ function shortPath(p) {
105
+ return p ? p.replace(/^\/(?:Users|home)\/[^/]+/, '~') : '';
106
+ }
107
+
108
+ // --- Session context menu ---
109
+
110
+ let menuCleanup = null;
111
+
112
+ function closeMenu() {
113
+ if (menuCleanup) menuCleanup();
114
+ }
115
+
116
+ function positionMenu(menu, anchorRect) {
117
+ menu.style.visibility = 'hidden';
118
+ document.body.appendChild(menu);
119
+ const mh = menu.offsetHeight;
120
+ const gap = 4;
121
+ const spaceBelow = window.innerHeight - anchorRect.bottom - gap;
122
+ menu.style.top = (spaceBelow >= mh
123
+ ? anchorRect.bottom + gap
124
+ : Math.max(gap, anchorRect.top - gap - mh)) + 'px';
125
+ menu.style.right = (window.innerWidth - anchorRect.right) + 'px';
126
+ menu.style.visibility = '';
127
+ }
128
+
129
+ function openMenu(sessionId, anchorEl) {
130
+ closeMenu();
131
+
132
+ const rect = anchorEl.getBoundingClientRect();
133
+ const menu = document.createElement('div');
134
+ menu.className = 'fixed z-[400] min-w-[160px] bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1';
135
+
136
+ const entry = state.terms.get(sessionId);
137
+ const projects = state.cfg.projects || [];
138
+
139
+ let html = '';
140
+
141
+ // Project submenu items
142
+ if (projects.length) {
143
+ html += `<div class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-slate-600">Move to project</div>`;
144
+ for (const p of projects) {
145
+ const active = entry?.projectId === p.id;
146
+ html += `<button class="menu-action flex items-center gap-2 w-full px-3 py-1.5 text-sm ${active ? 'text-blue-400' : 'text-slate-300'} hover:bg-slate-700 transition-colors text-left" data-action="project" data-project-id="${p.id}">
147
+ <span class="w-2 h-2 rounded-full flex-shrink-0" style="background:${projectColor(p)}"></span>
148
+ ${esc(p.name)}${active ? ' ✓' : ''}
149
+ </button>`;
150
+ }
151
+ if (entry?.projectId) {
152
+ html += `<button class="menu-action flex items-center gap-2 w-full px-3 py-1.5 text-sm text-slate-500 hover:bg-slate-700 transition-colors text-left" data-action="unproject">
153
+ <span class="w-2 h-2 rounded-full flex-shrink-0 border border-slate-600"></span>
154
+ Remove from project
155
+ </button>`;
156
+ }
157
+ html += `<div class="border-t border-slate-700/50 my-1"></div>`;
158
+ }
159
+
160
+ const muted = !!entry?.muted;
161
+ html += `
162
+ <button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="rename">
163
+ <span class="flex-shrink-0 text-slate-400"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span>
164
+ Rename
165
+ </button>
166
+ <button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="mute">
167
+ <span class="flex-shrink-0 text-slate-400"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${muted
168
+ ? '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>'
169
+ : '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>'}</svg></span>
170
+ ${muted ? 'Unmute' : 'Mute'}
171
+ </button>
172
+ <button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="theme">
173
+ <span class="flex-shrink-0 text-slate-400"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8"/></svg></span>
174
+ Theme
175
+ </button>
176
+ <button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-slate-300 hover:bg-slate-700 transition-colors text-left" data-action="refresh">
177
+ <span class="flex-shrink-0 text-slate-400"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v5h4.5M20 20v-5h-4.5M4 9a9 9 0 0 1 15.36-5.36M20 15a9 9 0 0 1-15.36 5.36"/></svg></span>
178
+ Refresh session
179
+ </button>
180
+ <button class="menu-action flex items-center gap-2.5 w-full px-3 py-2 text-sm text-red-400 hover:bg-slate-700 transition-colors text-left" data-action="delete">
181
+ <span class="flex-shrink-0 text-red-400"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg></span>
182
+ Delete
183
+ </button>`;
184
+
185
+ menu.innerHTML = html;
186
+ positionMenu(menu, rect);
187
+
188
+ const onClick = (e) => {
189
+ const btn = e.target.closest('.menu-action');
190
+ if (!btn) return;
191
+ closeMenu();
192
+ const action = btn.dataset.action;
193
+ if (action === 'rename') {
194
+ startRename(sessionId);
195
+ } else if (action === 'mute') {
196
+ toggleMute(sessionId);
197
+ } else if (action === 'refresh') {
198
+ const re = state.terms.get(sessionId);
199
+ if (re) send({ type: 'session.restart', id: sessionId, themeId: re.themeId, cols: re.term.cols, rows: re.term.rows });
200
+ } else if (action === 'delete') {
201
+ document.getElementById('session-list').dispatchEvent(
202
+ new CustomEvent('session-delete', { detail: { id: sessionId } })
203
+ );
204
+ } else if (action === 'theme') {
205
+ const iconEl = document.querySelector(`.group[data-id="${sessionId}"] .session-icon`);
206
+ if (iconEl) openThemePicker(sessionId, iconEl);
207
+ } else if (action === 'project') {
208
+ setSessionProject(sessionId, btn.dataset.projectId);
209
+ } else if (action === 'unproject') {
210
+ setSessionProject(sessionId, null);
211
+ }
212
+ };
213
+ const onOutside = (e) => {
214
+ if (!menu.contains(e.target)) closeMenu();
215
+ };
216
+ menu.addEventListener('click', onClick);
217
+ requestAnimationFrame(() => document.addEventListener('click', onOutside));
218
+
219
+ menuCleanup = () => {
220
+ menu.removeEventListener('click', onClick);
221
+ document.removeEventListener('click', onOutside);
222
+ menu.remove();
223
+ menuCleanup = null;
224
+ };
225
+ }
226
+
227
+ // --- Theme picker (per-session) ---
228
+
229
+ let pickerCleanup = null;
230
+
231
+ function openThemePicker(sessionId, anchorEl) {
232
+ closeThemePicker();
233
+ const entry = state.terms.get(sessionId);
234
+ if (!entry) return;
235
+
236
+ const rect = anchorEl.getBoundingClientRect();
237
+ const picker = document.createElement('div');
238
+ picker.className = 'fixed z-[400] min-w-[220px] max-h-[400px] overflow-y-auto bg-slate-800 border border-slate-600 rounded-lg shadow-xl shadow-black/40 py-1';
239
+ picker.style.top = (rect.bottom + 4) + 'px';
240
+ picker.style.left = rect.left + 'px';
241
+
242
+ const currentIsLight = isLightBg(entry.themeId);
243
+ picker.innerHTML = state.themes.map(t => {
244
+ const polarityFlip = isLightBg(t.id) !== currentIsLight;
245
+ return `<div class="theme-pick px-3 py-2 cursor-pointer hover:bg-slate-700 transition-colors ${t.id === entry.themeId ? 'bg-blue-500/15 border-l-2 border-blue-400' : ''}" data-theme="${t.id}">
246
+ <div class="text-sm text-slate-200 mb-1">${esc(t.name)}</div>
247
+ <div class="text-[10px] font-mono leading-[1.4] whitespace-pre rounded overflow-hidden" style="background:${t.theme.background};padding:4px 6px"><span style="color:${t.theme.green}">~</span> <span style="color:${t.theme.blue}">src</span> <span style="color:${t.theme.foreground}">$ ls</span>\n<span style="color:${t.theme.yellow}">app.ts</span> <span style="color:${t.theme.cyan}">utils.ts</span> <span style="color:${t.theme.brightBlack}">README</span></div>${polarityFlip ? '<div class="text-[10px] text-slate-500 mt-1">Restart session to apply color mode</div>' : ''}
248
+ </div>`;
249
+ }).join('');
250
+
251
+ document.body.appendChild(picker);
252
+
253
+ const onClick = (e) => {
254
+ const item = e.target.closest('.theme-pick');
255
+ if (item) setSessionTheme(sessionId, item.dataset.theme);
256
+ closeThemePicker();
257
+ };
258
+ const onOutside = (e) => {
259
+ if (!picker.contains(e.target) && e.target !== anchorEl) closeThemePicker();
260
+ };
261
+ picker.addEventListener('click', onClick);
262
+ requestAnimationFrame(() => document.addEventListener('click', onOutside));
263
+
264
+ pickerCleanup = () => {
265
+ picker.removeEventListener('click', onClick);
266
+ document.removeEventListener('click', onOutside);
267
+ picker.remove();
268
+ pickerCleanup = null;
269
+ };
270
+ }
271
+
272
+ function closeThemePicker() {
273
+ if (pickerCleanup) pickerCleanup();
274
+ }
275
+
276
+ // --- Terminal size estimation (for PTY spawn) ---
277
+
278
+ export function estimateSize() {
279
+ const el = document.getElementById('terminals');
280
+ // Account for inset-1 padding (4px each side)
281
+ const w = el.clientWidth - 8, h = el.clientHeight - 8;
282
+ // Menlo 13px: ~7.8px wide, ~17px tall
283
+ return { cols: Math.max(Math.floor(w / 7.8), 80), rows: Math.max(Math.floor(h / 17), 24) };
284
+ }
285
+
286
+ // --- Terminal management ---
287
+
288
+ export function addTerminal(id, name, themeId, commandId, projectId, muted, lastPreview) {
289
+ if (state.terms.has(id)) return;
290
+ themeId = themeId || state.cfg.defaultTheme || 'default';
291
+
292
+ const item = document.createElement('div');
293
+ item.className = 'group flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors select-none';
294
+ item.dataset.id = id;
295
+ item.innerHTML = `
296
+ <div class="session-icon w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center flex-shrink-0 overflow-hidden pointer-events-none">
297
+ ${iconHtml(commandId)}
298
+ </div>
299
+ <div class="flex-1 min-w-0 pointer-events-none">
300
+ <div class="flex items-baseline gap-2">
301
+ <span class="name flex-1 font-semibold text-[13px] text-slate-200 truncate pointer-events-auto cursor-default">${esc(name)}</span>
302
+ <span class="session-time recent text-[11px] flex-shrink-0">${formatTime(Date.now())}</span>
303
+ </div>
304
+ <div class="flex items-center gap-1 mt-0.5">
305
+ <span class="session-status flex-shrink-0 leading-none" style="transition:opacity 0.2s"></span>
306
+ <span class="session-preview flex-1 text-xs text-slate-500 truncate"></span>
307
+ <span class="unread-dot hidden w-2 h-2 rounded-full bg-blue-500 flex-shrink-0"></span>
308
+ <button class="menu-btn opacity-0 group-hover:opacity-100 text-slate-500 hover:text-slate-300 flex-shrink-0 transition-opacity pointer-events-auto" title="Menu">
309
+ <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 20 20"><path d="M10 14l-4-4h8l-4 4z" fill="currentColor"/></svg>
310
+ </button>
311
+ </div>
312
+ </div>`;
313
+
314
+ // Show saved preview from last session if available (survives reconnect/sleep)
315
+ if (lastPreview) item.querySelector('.session-preview').textContent = lastPreview;
316
+
317
+ document.getElementById('session-list').appendChild(item);
318
+ const statusEl = item.querySelector('.session-status');
319
+ const cmd = state.cfg.commands.find(c => c.id === commandId);
320
+ const hasBridge = !!cmd?.bridge;
321
+ const stopBounce = hasBridge ? null : startBounce(statusEl);
322
+
323
+ const el = document.createElement('div');
324
+ el.className = 'term-wrap';
325
+ el.style.backgroundColor = resolveTheme(themeId).background;
326
+ document.getElementById('terminals').appendChild(el);
327
+
328
+ const term = new Terminal({
329
+ fontSize: 13,
330
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
331
+ theme: resolveTheme(themeId),
332
+ // Keep ANSI/truecolor output readable across dark and light terminal themes.
333
+ minimumContrastRatio: MIN_CONTRAST_RATIO,
334
+ cursorBlink: true,
335
+ scrollback: 5000,
336
+ });
337
+ const fit = new FitAddon.FitAddon();
338
+ term.loadAddon(fit);
339
+ term.onData(data => send({ type: 'input', id, data }));
340
+
341
+ term.open(el);
342
+ attachToTerminal(term);
343
+ let fitted = false, pending = [];
344
+ const ro = new ResizeObserver(() => {
345
+ if (!el.offsetWidth) return;
346
+ fit.fit();
347
+ send({ type: 'resize', id, cols: term.cols, rows: term.rows });
348
+ if (!fitted) {
349
+ fitted = true;
350
+ for (const chunk of pending) term.write(chunk);
351
+ pending = null;
352
+ updatePreview(id);
353
+ }
354
+ });
355
+ ro.observe(el);
356
+ // Safety: if RO hasn't fired within 500ms, flush anyway to avoid unbounded queue
357
+ setTimeout(() => { if (!fitted) { fitted = true; for (const chunk of pending) term.write(chunk); pending = null; updatePreview(id); } }, 500);
358
+ state.terms.set(id, { term, fit, el, ro, themeId, commandId, projectId: projectId || null, muted: !!muted, working: !hasBridge, workStartedAt: hasBridge ? null : Date.now(), stopBounce, queue: (data) => { if (!fitted) { pending.push(data); return true; } return false; }, lastActivityAt: Date.now(), unread: false, lastPreviewText: lastPreview || '', searchText: '' });
359
+ document.getElementById('empty').style.display = 'none';
360
+ document.getElementById('terminals').style.pointerEvents = '';
361
+ if (muted) requestAnimationFrame(() => updateMuteIndicator(id));
362
+
363
+ regroupSessions();
364
+ }
365
+
366
+ export function removeTerminal(id) {
367
+ const entry = state.terms.get(id);
368
+ if (!entry) return;
369
+ if (entry.stopBounce) entry.stopBounce();
370
+ entry.ro?.disconnect();
371
+ entry.term.dispose();
372
+ entry.el.remove();
373
+ state.terms.delete(id);
374
+ document.querySelector(`.group[data-id="${id}"]`)?.remove();
375
+
376
+ if (state.active === id) {
377
+ const next = state.terms.keys().next().value;
378
+ if (next) select(next);
379
+ else {
380
+ state.active = null;
381
+ document.getElementById('empty').style.display = 'flex';
382
+ document.getElementById('terminals').style.pointerEvents = 'none';
383
+ }
384
+ }
385
+ regroupSessions();
386
+ }
387
+
388
+ export function select(id) {
389
+ if (state.active === id) return;
390
+ closeDropdown();
391
+
392
+ const prev = document.querySelector('.group.active-session');
393
+ if (prev) prev.classList.remove('active-session');
394
+ document.querySelector('.term-wrap.active')?.classList.remove('active');
395
+
396
+ const item = document.querySelector(`.group[data-id="${id}"]`);
397
+ if (item) item.classList.add('active-session');
398
+
399
+ const entry = state.terms.get(id);
400
+ if (entry) {
401
+ entry.el.classList.add('active');
402
+ if (entry.unread) {
403
+ entry.unread = false;
404
+ const dot = document.querySelector(`.group[data-id="${id}"] .unread-dot`);
405
+ if (dot) dot.classList.add('hidden');
406
+ updateUnreadBadge();
407
+ if (state.filter.tab === 'unread') setTab('all');
408
+ }
409
+ entry.term.scrollToBottom();
410
+ if (!document.querySelector('[contenteditable="true"]')) entry.term.focus();
411
+ }
412
+ state.active = id;
413
+ }
414
+
415
+ // --- Preview & status ---
416
+
417
+ export function markUnread(id) {
418
+ const entry = state.terms.get(id);
419
+ if (!entry || id === state.active || entry.unread) return;
420
+ entry.unread = true;
421
+ const dot = document.querySelector(`.group[data-id="${id}"] .unread-dot`);
422
+ if (dot) dot.classList.remove('hidden');
423
+ updateUnreadBadge();
424
+ applyFilter();
425
+ }
426
+
427
+ export function updatePreview(id) {
428
+ const entry = state.terms.get(id);
429
+ if (!entry) return;
430
+ const last = readLastAgentLine(entry.term, entry.commandId);
431
+ const el = document.querySelector(`.group[data-id="${id}"] .session-preview`);
432
+ if (el && last && el.textContent !== last) {
433
+ el.textContent = last;
434
+ entry.lastPreviewText = last;
435
+ entry.lastActivityAt = Date.now();
436
+ // Persist preview on server — picked up by 30s auto-save
437
+ send({ type: 'session.setPreview', id, text: last, timestamp: new Date().toISOString() });
438
+ }
439
+ const timeEl = document.querySelector(`.group[data-id="${id}"] .session-time`);
440
+ if (timeEl) updateTimeEl(timeEl, entry.lastActivityAt);
441
+ }
442
+
443
+ // Read the terminal buffer bottom-up, return the last line
444
+ // where most characters use default foreground and aren't dim
445
+ function readLineText(buf, y) {
446
+ const line = buf.getLine(y);
447
+ if (!line) return '';
448
+ let text = '';
449
+ for (let x = 0; x < line.length; x++) {
450
+ text += line.getCell(x)?.getChars() || ' ';
451
+ }
452
+ return text.trimEnd();
453
+ }
454
+
455
+ // Platform-specific marker alternatives.
456
+ // Claude Code uses ⏺ (U+23FA) on Mac but ● (U+25CF) on Windows.
457
+ const MARKER_ALTS = { '\u23FA': ['\u23FA', '\u25CF'] };
458
+
459
+ function readLastAgentLine(term, commandId) {
460
+ const marker = state.cfg.commands.find(c => c.id === commandId)?.outputMarker;
461
+ if (!marker) return '';
462
+ const markers = MARKER_ALTS[marker] || [marker];
463
+ const buf = term.buffer.active;
464
+ for (let y = buf.baseY + buf.cursorY; y >= 0; y--) {
465
+ const text = readLineText(buf, y).trim();
466
+ if (!text) continue;
467
+ const match = markers.find(m => text.startsWith(m));
468
+ if (!match) continue;
469
+ const content = text.slice(match.length).trim();
470
+ if (content) return content;
471
+ }
472
+ return '';
473
+ }
474
+
475
+ function setStatus(id, working) {
476
+ const entry = state.terms.get(id);
477
+ if (!entry || entry.working === working) return;
478
+
479
+ const wasWorking = entry.working;
480
+ entry.working = working;
481
+
482
+ // Notify on working → idle transition
483
+ if (wasWorking && !working && !entry.muted) {
484
+ const workDuration = (Date.now() - (entry.workStartedAt || 0)) / 1000;
485
+ const minWork = state.cfg.notifyMinWork || 10;
486
+ if (workDuration >= minWork) {
487
+ // Sound: plays unless this session is the one you're looking at
488
+ if (state.cfg.notifySoundEnabled !== false && state.active !== id) {
489
+ new Audio(`/fx/${(state.cfg.notifySound || 'default-beep')}.mp3`).play().catch(() => {});
490
+ }
491
+ // Browser notification: plays when the CliDeck tab is not focused
492
+ if (state.cfg.notifyIdle && !document.hasFocus()
493
+ && 'Notification' in window && Notification.permission === 'granted') {
494
+ const sessionName = document.querySelector(`.group[data-id="${id}"] .name`)?.textContent || 'Session';
495
+ const proj = state.cfg.projects?.find(p => p.id === entry.projectId);
496
+ const title = proj ? `${proj.name}: ${sessionName}` : sessionName;
497
+ const n = new Notification(title, { body: `Is now idle.\n${entry.lastPreviewText || ''}`, icon: '/img/clideck-logo-icon.png', tag: id });
498
+ n.onclick = () => { window.focus(); select(id); n.close(); };
499
+ }
500
+ }
501
+ }
502
+
503
+ if (working) entry.workStartedAt = Date.now();
504
+
505
+ const el = document.querySelector(`.group[data-id="${id}"] .session-status`);
506
+ if (!el) return;
507
+
508
+ // Stop previous animation if any
509
+ if (entry.stopBounce) { entry.stopBounce(); entry.stopBounce = null; }
510
+
511
+ // Fade out, swap, fade in
512
+ el.style.opacity = '0';
513
+ setTimeout(() => {
514
+ if (working) {
515
+ el.className = 'session-status flex-shrink-0 leading-none';
516
+ entry.stopBounce = startBounce(el);
517
+ } else {
518
+ el.className = 'session-status dormant flex-shrink-0 text-[11px] leading-none';
519
+ el.innerHTML = '<span>z<sup>z</sup>Z</span>';
520
+ }
521
+ el.style.opacity = '1';
522
+ }, 200);
523
+ }
524
+
525
+ // --- Mute ---
526
+
527
+ const MUTE_SVG = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`;
528
+
529
+ function toggleMute(id) {
530
+ const entry = state.terms.get(id);
531
+ if (!entry) return;
532
+ entry.muted = !entry.muted;
533
+ send({ type: 'session.mute', id, muted: entry.muted });
534
+ updateMuteIndicator(id);
535
+ }
536
+
537
+ function updateMuteIndicator(id) {
538
+ const entry = state.terms.get(id);
539
+ if (!entry) return;
540
+ const row = document.querySelector(`.group[data-id="${id}"]`);
541
+ if (!row) return;
542
+ let icon = row.querySelector('.mute-icon');
543
+ if (entry.muted) {
544
+ if (!icon) {
545
+ icon = document.createElement('span');
546
+ icon.className = 'mute-icon flex-shrink-0 text-slate-600';
547
+ icon.innerHTML = MUTE_SVG;
548
+ const dot = row.querySelector('.unread-dot');
549
+ dot.parentNode.insertBefore(icon, dot);
550
+ }
551
+ } else {
552
+ icon?.remove();
553
+ }
554
+ }
555
+
556
+ // --- Theme ---
557
+
558
+ export function setSessionTheme(id, themeId, { showBanner = true } = {}) {
559
+ const entry = state.terms.get(id);
560
+ if (!entry) return;
561
+ const oldLight = isLightBg(entry.themeId);
562
+ const newLight = isLightBg(themeId);
563
+ entry.themeId = themeId;
564
+ applyTheme(entry.term, themeId);
565
+ entry.el.style.backgroundColor = resolveTheme(themeId).background;
566
+ send({ type: 'session.theme', id, themeId });
567
+ if (showBanner && oldLight !== newLight) showRestartBanner(id, themeId);
568
+ else hideRestartBanner(id);
569
+ }
570
+
571
+ function showRestartBanner(id, themeId) {
572
+ const group = document.querySelector(`.group[data-id="${id}"]`);
573
+ if (!group || group.querySelector('.restart-banner')) return;
574
+ const entry = state.terms.get(id);
575
+ if (!entry) return;
576
+ const mode = isLightBg(themeId) ? 'light' : 'dark';
577
+ const banner = document.createElement('div');
578
+ banner.className = 'restart-banner flex items-center gap-1.5 px-2.5 py-1 text-[11px] text-blue-400 cursor-pointer hover:text-blue-300 transition-colors';
579
+ banner.style.marginLeft = '2.75rem'; // align with text (past icon)
580
+ banner.innerHTML = `<svg class="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h4.5M20 20v-5h-4.5M4 9a9 9 0 0 1 15.36-5.36M20 15a9 9 0 0 1-15.36 5.36"/></svg><span>Restart to apply ${mode} theme</span>`;
581
+ banner.addEventListener('click', (e) => {
582
+ e.stopPropagation();
583
+ console.log('[restart] click banner, sending session.restart', { id, themeId: entry.themeId });
584
+ send({ type: 'session.restart', id, themeId: entry.themeId, cols: entry.term.cols, rows: entry.term.rows });
585
+ });
586
+ group.appendChild(banner);
587
+ }
588
+
589
+ function hideRestartBanner(id) {
590
+ document.querySelector(`.group[data-id="${id}"] .restart-banner`)?.remove();
591
+ }
592
+
593
+ export function restartComplete(id, msg) {
594
+ hideRestartBanner(id);
595
+ const entry = state.terms.get(id);
596
+ if (!entry) return;
597
+ entry.term.clear();
598
+ if (state.active !== id) select(id);
599
+ else entry.term.focus();
600
+ }
601
+
602
+ // --- Rename ---
603
+
604
+ export function startRename(id) {
605
+ const el = document.querySelector(`.group[data-id="${id}"] .name`);
606
+ if (!el || el.contentEditable === 'true') return;
607
+ const original = el.textContent;
608
+ el.contentEditable = 'true';
609
+ el.style.userSelect = 'text';
610
+ el.style.webkitUserSelect = 'text';
611
+ el.classList.add('cursor-text');
612
+ el.focus();
613
+ document.getSelection().selectAllChildren(el);
614
+
615
+ let cancelled = false;
616
+ const finish = () => {
617
+ el.removeEventListener('keydown', onKey);
618
+ el.contentEditable = 'false';
619
+ el.style.userSelect = '';
620
+ el.style.webkitUserSelect = '';
621
+ el.classList.remove('cursor-text');
622
+ if (cancelled) el.textContent = original;
623
+ else {
624
+ const name = el.textContent.trim() || original;
625
+ el.textContent = name;
626
+ send({ type: 'rename', id, name });
627
+ }
628
+ };
629
+ const onKey = (e) => {
630
+ if (e.key === 'Enter') { e.preventDefault(); el.blur(); }
631
+ if (e.key === 'Escape') { cancelled = true; el.blur(); }
632
+ };
633
+ el.addEventListener('blur', finish, { once: true });
634
+ el.addEventListener('keydown', onKey);
635
+ }
636
+
637
+ export function startProjectRename(projectId) {
638
+ const el = document.querySelector(`.project-group[data-project-id="${projectId}"] .project-name`);
639
+ if (!el || el.contentEditable === 'true') return;
640
+ const original = el.textContent;
641
+ el.contentEditable = 'true';
642
+ el.classList.add('text-slate-200');
643
+ el.focus();
644
+ document.getSelection().selectAllChildren(el);
645
+
646
+ let cancelled = false;
647
+ const finish = () => {
648
+ el.removeEventListener('keydown', onKey);
649
+ el.contentEditable = 'false';
650
+ el.classList.remove('text-slate-200');
651
+ if (cancelled) { el.textContent = original; return; }
652
+ const name = el.textContent.trim() || original;
653
+ el.textContent = name;
654
+ const proj = (state.cfg.projects || []).find(p => p.id === projectId);
655
+ if (proj) {
656
+ proj.name = name;
657
+ send({ type: 'config.update', config: state.cfg });
658
+ }
659
+ };
660
+ const onKey = (e) => {
661
+ if (e.key === 'Enter') { e.preventDefault(); el.blur(); }
662
+ if (e.key === 'Escape') { cancelled = true; el.blur(); }
663
+ };
664
+ el.addEventListener('blur', finish, { once: true });
665
+ el.addEventListener('keydown', onKey);
666
+ }
667
+
668
+ // --- Project grouping ---
669
+
670
+ const CHEVRON_SVG = `<svg class="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>`;
671
+
672
+ const PROJECT_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#ef4444', '#06b6d4', '#84cc16'];
673
+
674
+ function projectColor(project) {
675
+ return project.color || PROJECT_COLORS[0];
676
+ }
677
+
678
+ export function regroupSessions() {
679
+ const list = document.getElementById('session-list');
680
+ const projects = state.cfg.projects || [];
681
+
682
+ // Detach all session rows (preserve DOM nodes)
683
+ const rows = new Map();
684
+ for (const [id] of state.terms) {
685
+ const row = document.querySelector(`.group[data-id="${id}"]`);
686
+ if (row) { row.remove(); rows.set(id, row); }
687
+ }
688
+ // Remove old project headers, resumable rows, and resumable section
689
+ list.querySelectorAll('.project-group').forEach(el => el.remove());
690
+ list.querySelectorAll('[data-resumable-id]').forEach(el => el.remove());
691
+ document.getElementById('resumable-section')?.remove();
692
+
693
+ // Render project groups
694
+ for (const proj of projects) {
695
+ const header = document.createElement('div');
696
+ header.className = 'project-group';
697
+ header.dataset.projectId = proj.id;
698
+
699
+ const collapsed = proj.collapsed;
700
+ header.innerHTML = `
701
+ <div class="group project-header flex items-center gap-1.5 px-2.5 py-1.5 cursor-pointer hover:bg-slate-800/30 transition-colors select-none" data-project-id="${proj.id}">
702
+ <span class="project-chevron ${collapsed ? 'collapsed' : ''} text-slate-500">${CHEVRON_SVG}</span>
703
+ <span class="w-2 h-2 rounded-full flex-shrink-0" style="background:${projectColor(proj)}"></span>
704
+ <span class="project-name flex-1 text-[11px] font-semibold uppercase tracking-wider text-slate-500 truncate">${esc(proj.name)}</span>
705
+ <span class="project-count text-[10px] text-slate-600">0</span>
706
+ <button class="project-menu-btn opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-400 flex-shrink-0 transition-opacity p-0.5" title="Project menu">
707
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5" fill="currentColor"/><circle cx="10" cy="10" r="1.5" fill="currentColor"/><circle cx="10" cy="16" r="1.5" fill="currentColor"/></svg>
708
+ </button>
709
+ </div>
710
+ <div class="project-sessions ${collapsed ? 'hidden' : ''}"></div>`;
711
+
712
+ list.appendChild(header);
713
+ }
714
+
715
+ // Place active sessions into their groups or ungrouped at top
716
+ const ungrouped = [];
717
+ for (const [id, entry] of state.terms) {
718
+ const row = rows.get(id);
719
+ if (!row) continue;
720
+ if (entry.projectId) {
721
+ const container = list.querySelector(`.project-group[data-project-id="${entry.projectId}"] .project-sessions`);
722
+ if (container) { container.appendChild(row); continue; }
723
+ }
724
+ ungrouped.push(row);
725
+ }
726
+
727
+ const firstGroup = list.querySelector('.project-group');
728
+ for (const row of ungrouped) list.insertBefore(row, firstGroup);
729
+
730
+ // Place resumable sessions into their project groups or ungrouped section
731
+ const ungroupedResumable = [];
732
+ for (const s of state.resumable) {
733
+ const row = buildResumableRow(s);
734
+ if (s.projectId) {
735
+ const container = list.querySelector(`.project-group[data-project-id="${s.projectId}"] .project-sessions`);
736
+ if (container) { container.appendChild(row); continue; }
737
+ }
738
+ ungroupedResumable.push(row);
739
+ }
740
+
741
+ // Ungrouped resumable → "Previous Sessions" section at the bottom
742
+ if (ungroupedResumable.length) {
743
+ const section = document.createElement('div');
744
+ section.id = 'resumable-section';
745
+ section.innerHTML = `<div class="resumable-header group flex items-center gap-1.5 px-2.5 py-2 mt-1 border-t border-slate-700/50">
746
+ <span class="flex-1 text-[10px] font-semibold uppercase tracking-wider text-slate-600">Previous Sessions</span>
747
+ <button class="prev-sessions-menu-btn opacity-0 group-hover:opacity-100 text-slate-600 hover:text-slate-400 flex-shrink-0 transition-opacity p-0.5" title="Previous sessions menu">
748
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5" fill="currentColor"/><circle cx="10" cy="10" r="1.5" fill="currentColor"/><circle cx="10" cy="16" r="1.5" fill="currentColor"/></svg>
749
+ </button>
750
+ </div>`;
751
+ for (const row of ungroupedResumable) section.appendChild(row);
752
+ list.appendChild(section);
753
+ }
754
+
755
+ // Update counts (active + resumable inside each project)
756
+ for (const proj of projects) {
757
+ const container = list.querySelector(`.project-group[data-project-id="${proj.id}"] .project-sessions`);
758
+ const countEl = list.querySelector(`.project-group[data-project-id="${proj.id}"] .project-count`);
759
+ if (container && countEl) countEl.textContent = container.children.length;
760
+ }
761
+
762
+ applyFilter();
763
+ }
764
+
765
+ export function toggleProjectCollapse(projectId) {
766
+ const proj = (state.cfg.projects || []).find(p => p.id === projectId);
767
+ if (!proj) return;
768
+ proj.collapsed = !proj.collapsed;
769
+ send({ type: 'config.update', config: state.cfg });
770
+
771
+ const group = document.querySelector(`.project-group[data-project-id="${projectId}"]`);
772
+ if (!group) return;
773
+ const sessions = group.querySelector('.project-sessions');
774
+ const chevron = group.querySelector('.project-chevron');
775
+ if (sessions) sessions.classList.toggle('hidden', proj.collapsed);
776
+ if (chevron) chevron.classList.toggle('collapsed', proj.collapsed);
777
+ }
778
+
779
+ export function setSessionProject(id, projectId) {
780
+ const entry = state.terms.get(id);
781
+ if (!entry) return;
782
+ entry.projectId = projectId;
783
+ send({ type: 'session.setProject', id, projectId });
784
+ regroupSessions();
785
+ }
786
+
787
+ // --- Resumable sessions ---
788
+
789
+ const RESUME_SVG = `<svg class="w-3 h-3 ml-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>`;
790
+
791
+ function buildResumableRow(s) {
792
+ const cmd = state.cfg.commands.find(c => c.id === s.commandId);
793
+ const label = cmd?.label || 'Session';
794
+ const time = formatTime(new Date(s.savedAt).getTime());
795
+ const path = shortPath(s.cwd);
796
+ const row = document.createElement('div');
797
+ row.className = 'group resumable-row flex items-center gap-2 px-2.5 py-2 cursor-pointer hover:bg-slate-800/30 transition-colors';
798
+ row.dataset.resumableId = s.id;
799
+ row.innerHTML = `
800
+ <div class="w-8 h-8 rounded-full bg-slate-800/50 flex items-center justify-center flex-shrink-0 overflow-hidden opacity-40">
801
+ ${iconHtml(s.commandId)}
802
+ </div>
803
+ <div class="flex-1 min-w-0">
804
+ <div class="flex items-baseline gap-2">
805
+ <span class="resumable-name flex-1 font-semibold text-[13px] text-slate-400 truncate">${esc(s.name)}</span>
806
+ <span class="text-[11px] text-slate-600 flex-shrink-0">${time}</span>
807
+ </div>
808
+ <div class="flex items-center gap-1 mt-0.5">
809
+ <span class="flex-1 text-xs text-slate-600 truncate">${s.lastPreview ? esc(s.lastPreview) : esc(label) + (path ? ' · ' + esc(path) : '')}</span>
810
+ <button class="resume-btn opacity-0 group-hover:opacity-100 text-slate-600 hover:text-emerald-400 flex-shrink-0 transition-all flex items-center gap-0.5 text-[11px] font-medium" title="Resume session">
811
+ Resume${RESUME_SVG}
812
+ </button>
813
+ </div>
814
+ </div>`;
815
+ return row;
816
+ }
817
+
818
+ export function renderResumable() {
819
+ // Just rebuild rows and let regroupSessions place them
820
+ regroupSessions();
821
+ }
822
+
823
+ // --- Filtering ---
824
+
825
+ export function applyFilter() {
826
+ const { query, tab } = state.filter;
827
+ const q = query.toLowerCase();
828
+
829
+ // Filter active sessions
830
+ for (const [id, entry] of state.terms) {
831
+ const el = document.querySelector(`.group[data-id="${id}"]`);
832
+ if (!el) continue;
833
+ const matchTab = tab === 'all' || entry.unread;
834
+ const name = el.querySelector('.name')?.textContent.toLowerCase() || '';
835
+ const matchQuery = !q || name.includes(q) || (entry.searchText || '').toLowerCase().includes(q);
836
+ el.style.display = matchTab && matchQuery ? '' : 'none';
837
+ }
838
+
839
+ // Filter all resumable rows (both inside projects and ungrouped)
840
+ for (const row of document.querySelectorAll('[data-resumable-id]')) {
841
+ if (tab === 'unread') { row.style.display = 'none'; continue; }
842
+ const name = row.querySelector('.resumable-name')?.textContent.toLowerCase() || '';
843
+ const tx = (state.transcriptCache?.[row.dataset.resumableId] || '').toLowerCase();
844
+ row.style.display = !q || name.includes(q) || tx.includes(q) ? '' : 'none';
845
+ }
846
+
847
+ // Show/hide project groups
848
+ for (const group of document.querySelectorAll('.project-group')) {
849
+ const sessions = group.querySelector('.project-sessions');
850
+ const hasVisible = sessions && [...sessions.children].some(c => c.style.display !== 'none');
851
+ let show;
852
+ if (q) {
853
+ const projName = group.querySelector('.project-name')?.textContent.toLowerCase() || '';
854
+ show = projName.includes(q) || hasVisible;
855
+ } else {
856
+ show = tab === 'all' || hasVisible;
857
+ }
858
+ group.style.display = show ? '' : 'none';
859
+ }
860
+
861
+ // Ungrouped resumable section
862
+ const section = document.getElementById('resumable-section');
863
+ if (!section) return;
864
+ if (tab === 'unread') { section.style.display = 'none'; return; }
865
+ section.style.display = '';
866
+ const anyVisible = [...section.querySelectorAll('[data-resumable-id]')].some(r => r.style.display !== 'none');
867
+ const header = section.querySelector('.resumable-header');
868
+ if (header) header.style.display = anyVisible ? '' : 'none';
869
+ }
870
+
871
+ export function setTab(tab) {
872
+ state.filter.tab = tab;
873
+ document.querySelectorAll('.filter-tab').forEach(btn => {
874
+ const active = btn.dataset.tab === tab;
875
+ const base = 'filter-tab flex-1 text-[11px] font-medium py-[5px] rounded-md transition-all';
876
+ const extra = btn.dataset.tab === 'unread' ? ' flex items-center justify-center gap-1' : '';
877
+ btn.className = base + extra + (active ? ' bg-slate-700/60 text-slate-200' : ' text-slate-500 hover:text-slate-400');
878
+ });
879
+ applyFilter();
880
+ }
881
+
882
+ function updateUnreadBadge() {
883
+ let count = 0;
884
+ for (const [, entry] of state.terms) if (entry.unread) count++;
885
+ const badge = document.getElementById('unread-badge');
886
+ if (badge) {
887
+ badge.textContent = count || '';
888
+ badge.classList.toggle('hidden', count === 0);
889
+ }
890
+ const rail = document.getElementById('rail-unread');
891
+ if (rail) {
892
+ rail.textContent = count || '';
893
+ rail.classList.toggle('hidden', count === 0);
894
+ }
895
+ }
896
+
897
+ // Refresh displayed timestamps every 60s so they age naturally
898
+ setInterval(() => {
899
+ for (const [id, entry] of state.terms) {
900
+ const timeEl = document.querySelector(`.group[data-id="${id}"] .session-time`);
901
+ if (timeEl) updateTimeEl(timeEl, entry.lastActivityAt);
902
+ }
903
+ }, 60000);
904
+
905
+ export { openMenu, closeMenu, setStatus, updateMuteIndicator, positionMenu, PROJECT_COLORS };