@shawnowen/comet-mcp 2.3.0

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.
@@ -0,0 +1,1186 @@
1
+ // ─── Comet Session Manager — Side Panel ───────────────────────────────────────
2
+ // T008: Base module structure with message helper, DOM references,
3
+ // section toggle logic, and initialization entry point.
4
+
5
+ 'use strict';
6
+
7
+ // ─── Message Helper ───────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Send a typed message to background.js and return response data.
11
+ * Follows contract: { type, payload } → { ok, data, error }
12
+ * @param {string} type - Message type (e.g., 'getGroups')
13
+ * @param {object} [payload={}] - Optional payload
14
+ * @returns {Promise<any>} Response data
15
+ */
16
+ async function sendMessage(type, payload = {}) {
17
+ return new Promise((resolve, reject) => {
18
+ chrome.runtime.sendMessage({ type, payload }, (response) => {
19
+ if (chrome.runtime.lastError) {
20
+ reject(new Error(chrome.runtime.lastError.message));
21
+ return;
22
+ }
23
+ if (!response) {
24
+ reject(new Error(`No response for message type "${type}"`));
25
+ return;
26
+ }
27
+ if (response.ok) {
28
+ resolve(response.data);
29
+ } else {
30
+ reject(new Error(response.error || `Handler error for "${type}"`));
31
+ }
32
+ });
33
+ });
34
+ }
35
+
36
+ // ─── DOM References ───────────────────────────────────────────────────────────
37
+
38
+ const $ = (sel) => document.querySelector(sel);
39
+ const $$ = (sel) => document.querySelectorAll(sel);
40
+
41
+ const DOM = {
42
+ // Search
43
+ searchInput: $('#search-input'),
44
+ searchClear: $('#search-clear'),
45
+
46
+ // Sections
47
+ sectionHeaders: $$('.section-header'),
48
+ liveContent: $('#live-content'),
49
+ archivedContent: $('#archived-content'),
50
+ recentContent: $('#recent-content'),
51
+ liveCount: $('#live-count'),
52
+ archivedCount: $('#archived-count'),
53
+ recentCount: $('#recent-count'),
54
+
55
+ // Empty states
56
+ liveEmpty: $('#live-empty'),
57
+ archivedEmpty: $('#archived-empty'),
58
+ recentEmpty: $('#recent-empty'),
59
+
60
+ // Status bar
61
+ statusDot: $('#status-dot'),
62
+ statusText: $('#status-text'),
63
+ btnReconnect: $('#btn-reconnect'),
64
+
65
+ // Toolbar
66
+ btnSaveAll: $('#btn-save-all'),
67
+ btnImport: $('#btn-import'),
68
+ btnExport: $('#btn-export'),
69
+
70
+ // Import modal
71
+ importOverlay: $('#import-overlay'),
72
+ importTextarea: $('#import-textarea'),
73
+ importPreview: $('#import-preview'),
74
+ importDuplicates: $('#import-duplicates'),
75
+ importDuplicateText: $('#import-duplicate-text'),
76
+ importCancel: $('#import-cancel'),
77
+ importConfirm: $('#import-confirm'),
78
+ importClose: $('#import-close'),
79
+
80
+ // Container
81
+ sectionsContainer: $('#sections-container'),
82
+ };
83
+
84
+ // ─── State ────────────────────────────────────────────────────────────────────
85
+
86
+ const state = {
87
+ groups: [], // Live TabGroup[]
88
+ tabs: [], // Live Tab[]
89
+ archived: [], // ArchiveEntry[]
90
+ recentlyClosed: [], // Session[]
91
+ expandedGroups: new Set(), // Group IDs currently expanded
92
+ expandedSections: new Set(['live']), // Section names currently expanded
93
+ searchQuery: '',
94
+ cdpConnected: false,
95
+ refreshTimer: null,
96
+ healthTimer: null,
97
+ };
98
+
99
+ // ─── Utility Helpers ──────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Format a timestamp to a relative time string (e.g., "2 min ago")
103
+ */
104
+ function timeAgo(ts) {
105
+ const seconds = Math.floor((Date.now() - ts) / 1000);
106
+ if (seconds < 60) return 'just now';
107
+ const minutes = Math.floor(seconds / 60);
108
+ if (minutes < 60) return `${minutes} min ago`;
109
+ const hours = Math.floor(minutes / 60);
110
+ if (hours < 24) return `${hours}h ago`;
111
+ const days = Math.floor(hours / 24);
112
+ return `${days}d ago`;
113
+ }
114
+
115
+ /**
116
+ * Extract hostname from a URL for display
117
+ */
118
+ function hostname(url) {
119
+ try {
120
+ return new URL(url).hostname.replace(/^www\./, '');
121
+ } catch {
122
+ return url;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Escape HTML to prevent XSS in dynamic content
128
+ */
129
+ function escapeHtml(str) {
130
+ const div = document.createElement('div');
131
+ div.textContent = str;
132
+ return div.innerHTML;
133
+ }
134
+
135
+ /**
136
+ * Create an element with attributes and children
137
+ */
138
+ function el(tag, attrs = {}, ...children) {
139
+ const elem = document.createElement(tag);
140
+ for (const [key, val] of Object.entries(attrs)) {
141
+ if (key === 'className') elem.className = val;
142
+ else if (key === 'dataset') Object.assign(elem.dataset, val);
143
+ else if (key.startsWith('on')) elem.addEventListener(key.slice(2).toLowerCase(), val);
144
+ else elem.setAttribute(key, val);
145
+ }
146
+ for (const child of children) {
147
+ if (typeof child === 'string') elem.appendChild(document.createTextNode(child));
148
+ else if (child) elem.appendChild(child);
149
+ }
150
+ return elem;
151
+ }
152
+
153
+ // ─── Section Toggle Logic ─────────────────────────────────────────────────────
154
+
155
+ function initSectionToggles() {
156
+ DOM.sectionHeaders.forEach((header) => {
157
+ const sectionName = header.dataset.section;
158
+ const content = $(`#${sectionName}-content`);
159
+ if (!content) return;
160
+
161
+ // Set initial state
162
+ if (state.expandedSections.has(sectionName)) {
163
+ header.classList.add('expanded');
164
+ content.classList.remove('collapsed');
165
+ } else {
166
+ header.classList.remove('expanded');
167
+ content.classList.add('collapsed');
168
+ }
169
+
170
+ header.addEventListener('click', () => {
171
+ const isExpanded = state.expandedSections.has(sectionName);
172
+ if (isExpanded) {
173
+ state.expandedSections.delete(sectionName);
174
+ header.classList.remove('expanded');
175
+ content.classList.add('collapsed');
176
+ } else {
177
+ state.expandedSections.add(sectionName);
178
+ header.classList.add('expanded');
179
+ content.classList.remove('collapsed');
180
+ }
181
+ });
182
+ });
183
+ }
184
+
185
+ // ─── Search Logic (T024-T026) ─────────────────────────────────────────────────
186
+
187
+ function initSearch() {
188
+ let debounceTimer = null;
189
+
190
+ DOM.searchInput.addEventListener('input', () => {
191
+ clearTimeout(debounceTimer);
192
+ const query = DOM.searchInput.value.trim();
193
+
194
+ DOM.searchClear.classList.toggle('hidden', query.length === 0);
195
+
196
+ debounceTimer = setTimeout(() => {
197
+ state.searchQuery = query.toLowerCase();
198
+ applySearchFilter();
199
+ }, 150);
200
+ });
201
+
202
+ DOM.searchClear.addEventListener('click', () => {
203
+ DOM.searchInput.value = '';
204
+ DOM.searchClear.classList.add('hidden');
205
+ state.searchQuery = '';
206
+ applySearchFilter();
207
+ DOM.searchInput.focus();
208
+ });
209
+ }
210
+
211
+ function applySearchFilter() {
212
+ if (!state.searchQuery) {
213
+ // Show all items
214
+ document.querySelectorAll('.group-node, .archive-item, .recent-item, .tab-item, .window-header')
215
+ .forEach(el => el.style.display = '');
216
+ const noResults = document.querySelector('.no-results');
217
+ if (noResults) noResults.remove();
218
+ return;
219
+ }
220
+
221
+ // Basic search implementation — filter visible items
222
+ const query = state.searchQuery;
223
+ let matchCount = 0;
224
+
225
+ // Filter live groups and tabs
226
+ document.querySelectorAll('.group-node').forEach(node => {
227
+ const title = (node.dataset.title || '').toLowerCase();
228
+ const hasMatch = title.includes(query);
229
+ // Check child tabs
230
+ let childMatch = false;
231
+ node.querySelectorAll('.tab-item').forEach(tab => {
232
+ const tabTitle = (tab.dataset.title || '').toLowerCase();
233
+ const tabUrl = (tab.dataset.url || '').toLowerCase();
234
+ const tabMatch = tabTitle.includes(query) || tabUrl.includes(query);
235
+ tab.style.display = tabMatch ? '' : 'none';
236
+ if (tabMatch) childMatch = true;
237
+ });
238
+ const visible = hasMatch || childMatch;
239
+ node.style.display = visible ? '' : 'none';
240
+ if (visible) matchCount++;
241
+ });
242
+
243
+ // Filter archived items
244
+ document.querySelectorAll('.archive-item').forEach(item => {
245
+ const title = (item.dataset.title || '').toLowerCase();
246
+ const visible = title.includes(query);
247
+ item.style.display = visible ? '' : 'none';
248
+ if (visible) matchCount++;
249
+ });
250
+
251
+ // Filter recently closed items
252
+ document.querySelectorAll('.recent-item').forEach(item => {
253
+ const title = (item.dataset.title || '').toLowerCase();
254
+ const url = (item.dataset.url || '').toLowerCase();
255
+ const visible = title.includes(query) || url.includes(query);
256
+ item.style.display = visible ? '' : 'none';
257
+ if (visible) matchCount++;
258
+ });
259
+
260
+ // Show/hide no results
261
+ let noResults = document.querySelector('.no-results');
262
+ if (matchCount === 0 && !noResults) {
263
+ noResults = el('div', { className: 'no-results' },
264
+ el('span', { className: 'no-results-icon' }, '🔍'),
265
+ el('span', {}, 'No results found')
266
+ );
267
+ DOM.sectionsContainer.appendChild(noResults);
268
+ } else if (matchCount > 0 && noResults) {
269
+ noResults.remove();
270
+ }
271
+ }
272
+
273
+ // ─── Connection Status (T012) ─────────────────────────────────────────────────
274
+
275
+ async function checkHealth() {
276
+ try {
277
+ const result = await sendMessage('healthCheck');
278
+ state.cdpConnected = result.cdpConnected;
279
+ updateStatusUI(true, result.version);
280
+ } catch {
281
+ state.cdpConnected = false;
282
+ updateStatusUI(false);
283
+ }
284
+ }
285
+
286
+ function updateStatusUI(connected, version = '') {
287
+ if (connected) {
288
+ DOM.statusDot.className = 'status-dot connected';
289
+ DOM.statusText.textContent = `Connected v${version}`;
290
+ DOM.btnReconnect.classList.add('hidden');
291
+ } else {
292
+ DOM.statusDot.className = 'status-dot disconnected';
293
+ DOM.statusText.textContent = 'Disconnected';
294
+ DOM.btnReconnect.classList.remove('hidden');
295
+ }
296
+ }
297
+
298
+ // ─── Tree Rendering (T009) ───────────────────────────────────────────────────
299
+
300
+ async function fetchAndRenderLiveTree() {
301
+ try {
302
+ const [groups, tabs] = await Promise.all([
303
+ sendMessage('getGroups'),
304
+ sendMessage('getTabs'),
305
+ ]);
306
+ state.groups = groups;
307
+ state.tabs = tabs;
308
+ renderLiveTree(groups, tabs);
309
+ } catch (err) {
310
+ console.error('[Comet] Failed to fetch live tree:', err);
311
+ }
312
+ }
313
+
314
+ function renderLiveTree(groups, tabs) {
315
+ // Group tabs by windowId → groupId
316
+ const windowMap = new Map(); // windowId → { groups: Map<groupId, group>, tabs: Map<groupId, tab[]> }
317
+
318
+ for (const group of groups) {
319
+ if (!windowMap.has(group.windowId)) {
320
+ windowMap.set(group.windowId, { groups: new Map(), ungroupedTabs: [] });
321
+ }
322
+ windowMap.get(group.windowId).groups.set(group.id, group);
323
+ }
324
+
325
+ for (const tab of tabs) {
326
+ if (!windowMap.has(tab.windowId)) {
327
+ windowMap.set(tab.windowId, { groups: new Map(), ungroupedTabs: [] });
328
+ }
329
+ const win = windowMap.get(tab.windowId);
330
+ if (tab.groupId === -1) {
331
+ // Track ungrouped tabs per window
332
+ win.ungroupedTabs.push(tab);
333
+ continue;
334
+ }
335
+ if (!win.groups.has(tab.groupId)) continue; // skip tabs without known group
336
+ }
337
+
338
+ // Build DOM
339
+ const container = DOM.liveContent;
340
+ const existingEmpty = DOM.liveEmpty;
341
+
342
+ // Clear existing tree (preserve empty state element)
343
+ const children = Array.from(container.children);
344
+ children.forEach(child => {
345
+ if (child !== existingEmpty) child.remove();
346
+ });
347
+
348
+ let totalGroups = 0;
349
+ const sortedWindows = [...windowMap.entries()].sort((a, b) => a[0] - b[0]);
350
+
351
+ for (const [windowId, winData] of sortedWindows) {
352
+ if (winData.groups.size === 0) continue;
353
+
354
+ // Window header
355
+ if (sortedWindows.length > 1) {
356
+ container.appendChild(el('div', { className: 'window-header' }, `Window ${windowId}`));
357
+ }
358
+
359
+ // Groups in this window
360
+ for (const [groupId, group] of winData.groups) {
361
+ totalGroups++;
362
+ const groupTabs = tabs.filter(t => t.groupId === groupId);
363
+ const isExpanded = state.expandedGroups.has(String(groupId));
364
+
365
+ const groupNode = createGroupNode(group, groupTabs, isExpanded);
366
+ container.appendChild(groupNode);
367
+ }
368
+
369
+ // Ungrouped tabs section per window (T009 edge case)
370
+ if (winData.ungroupedTabs.length > 0) {
371
+ const ungroupedId = `ungrouped-${windowId}`;
372
+ const isExpanded = state.expandedGroups.has(ungroupedId);
373
+ const ungroupedGroup = {
374
+ id: ungroupedId,
375
+ title: 'Ungrouped',
376
+ color: 'grey',
377
+ collapsed: !isExpanded,
378
+ windowId,
379
+ };
380
+ const ungroupedNode = createGroupNode(ungroupedGroup, winData.ungroupedTabs, isExpanded);
381
+ container.appendChild(ungroupedNode);
382
+ }
383
+ }
384
+
385
+ // Update count and empty state
386
+ DOM.liveCount.textContent = String(totalGroups);
387
+ existingEmpty.classList.toggle('hidden', totalGroups > 0);
388
+
389
+ // Re-apply search filter if active
390
+ if (state.searchQuery) applySearchFilter();
391
+ }
392
+
393
+ function createGroupNode(group, groupTabs, isExpanded) {
394
+ const node = el('div', {
395
+ className: 'group-node',
396
+ dataset: { groupId: String(group.id), title: group.title }
397
+ });
398
+
399
+ // Group header
400
+ const header = el('div', {
401
+ className: `group-header${isExpanded ? ' expanded' : ''}`,
402
+ onClick: (e) => {
403
+ if (e.target.closest('.action-btn')) return;
404
+ toggleGroupExpand(group.id, header, tabList);
405
+ }
406
+ },
407
+ el('span', { className: 'group-chevron' }, '▸'),
408
+ el('span', { className: `group-color-badge badge-${group.color}` }),
409
+ el('span', { className: 'group-title', title: group.title }, group.title || 'Untitled'),
410
+ el('span', { className: 'group-tab-count' }, `${groupTabs.length}`),
411
+ el('div', { className: 'group-actions' },
412
+ el('button', {
413
+ className: 'action-btn archive-btn',
414
+ title: 'Archive group',
415
+ onClick: () => archiveGroupAction(group.id, group.title)
416
+ }, '📦'),
417
+ el('button', {
418
+ className: 'action-btn close-btn',
419
+ title: 'Close group',
420
+ onClick: () => closeGroupAction(group.id)
421
+ }, '×')
422
+ )
423
+ );
424
+
425
+ // Tab list
426
+ const tabList = el('div', {
427
+ className: `tab-list${isExpanded ? '' : ' collapsed'}`
428
+ });
429
+
430
+ for (const tab of groupTabs) {
431
+ tabList.appendChild(createTabItem(tab));
432
+ }
433
+
434
+ node.appendChild(header);
435
+ node.appendChild(tabList);
436
+ return node;
437
+ }
438
+
439
+ function createTabItem(tab) {
440
+ const favicon = tab.favIconUrl
441
+ ? el('img', { className: 'tab-favicon', src: tab.favIconUrl, alt: '' })
442
+ : el('span', { className: 'tab-favicon-placeholder' }, '●');
443
+
444
+ // Handle favicon load errors
445
+ if (favicon.tagName === 'IMG') {
446
+ favicon.onerror = () => {
447
+ const placeholder = el('span', { className: 'tab-favicon-placeholder' }, '●');
448
+ favicon.replaceWith(placeholder);
449
+ };
450
+ }
451
+
452
+ return el('div', {
453
+ className: `tab-item${tab.active ? ' active' : ''}`,
454
+ dataset: { tabId: String(tab.id), title: tab.title, url: tab.url },
455
+ onClick: (e) => {
456
+ if (e.target.closest('.tab-close')) return;
457
+ focusTabAction(tab.id, tab.windowId);
458
+ }
459
+ },
460
+ favicon,
461
+ el('span', { className: 'tab-title', title: tab.title }, tab.title || 'Untitled'),
462
+ el('span', { className: 'tab-url' }, hostname(tab.url)),
463
+ el('button', {
464
+ className: 'tab-close',
465
+ title: 'Close tab',
466
+ onClick: () => closeTabAction(tab.id)
467
+ }, '×')
468
+ );
469
+ }
470
+
471
+ function toggleGroupExpand(groupId, header, tabList) {
472
+ const key = String(groupId);
473
+ const isExpanded = state.expandedGroups.has(key);
474
+
475
+ if (isExpanded) {
476
+ state.expandedGroups.delete(key);
477
+ header.classList.remove('expanded');
478
+ tabList.classList.add('collapsed');
479
+ } else {
480
+ state.expandedGroups.add(key);
481
+ header.classList.add('expanded');
482
+ tabList.classList.remove('collapsed');
483
+ }
484
+
485
+ // Persist expanded state
486
+ chrome.storage.local.set({ expandedGroups: [...state.expandedGroups] });
487
+ }
488
+
489
+ // ─── Archived Sessions Rendering (T018) ──────────────────────────────────────
490
+
491
+ async function fetchAndRenderArchived() {
492
+ try {
493
+ const archived = await sendMessage('getArchivedGroups');
494
+ state.archived = archived || [];
495
+ renderArchived(state.archived);
496
+ } catch (err) {
497
+ console.error('[Comet] Failed to fetch archived groups:', err);
498
+ state.archived = [];
499
+ renderArchived([]);
500
+ }
501
+ }
502
+
503
+ function renderArchived(entries) {
504
+ const container = DOM.archivedContent;
505
+ const existingEmpty = DOM.archivedEmpty;
506
+
507
+ // Clear existing
508
+ Array.from(container.children).forEach(child => {
509
+ if (child !== existingEmpty) child.remove();
510
+ });
511
+
512
+ DOM.archivedCount.textContent = String(entries.length);
513
+ existingEmpty.classList.toggle('hidden', entries.length > 0);
514
+
515
+ for (const entry of entries) {
516
+ const archiveKey = `archive-${entry.taskThreadId}`;
517
+ const isExpanded = state.expandedGroups.has(archiveKey);
518
+ const urls = entry.urls || [];
519
+
520
+ const archiveNode = el('div', {
521
+ className: 'group-node archive-node',
522
+ dataset: { threadId: entry.taskThreadId, title: entry.title }
523
+ });
524
+
525
+ // Expandable tab list showing individual URLs
526
+ const tabList = el('div', {
527
+ className: `tab-list${isExpanded ? '' : ' collapsed'}`
528
+ });
529
+
530
+ for (const tabEntry of urls) {
531
+ tabList.appendChild(el('div', {
532
+ className: 'tab-item archive-tab-item',
533
+ dataset: { title: tabEntry.title || '', url: tabEntry.url || '' },
534
+ onClick: () => restoreSingleTabAction(entry.taskThreadId, tabEntry.url)
535
+ },
536
+ el('span', { className: 'tab-favicon-placeholder' }, '🔗'),
537
+ el('span', { className: 'tab-title', title: tabEntry.title || tabEntry.url }, tabEntry.title || hostname(tabEntry.url)),
538
+ el('span', { className: 'tab-url' }, hostname(tabEntry.url || ''))
539
+ ));
540
+ }
541
+
542
+ // Archive group header — clickable to expand/collapse
543
+ const header = el('div', {
544
+ className: `group-header archive-item${isExpanded ? ' expanded' : ''}`,
545
+ onClick: (e) => {
546
+ if (e.target.closest('.action-btn') || e.target.closest('.rename-input')) return;
547
+ const nowExpanded = state.expandedGroups.has(archiveKey);
548
+ if (nowExpanded) {
549
+ state.expandedGroups.delete(archiveKey);
550
+ header.classList.remove('expanded');
551
+ tabList.classList.add('collapsed');
552
+ } else {
553
+ state.expandedGroups.add(archiveKey);
554
+ header.classList.add('expanded');
555
+ tabList.classList.remove('collapsed');
556
+ }
557
+ }
558
+ },
559
+ el('span', { className: 'group-chevron' }, '▸'),
560
+ el('span', { className: `group-color-badge badge-${entry.color || 'grey'}` }),
561
+ el('span', {
562
+ className: 'group-title archive-title',
563
+ title: 'Double-click to rename',
564
+ onDblclick: (e) => startRename(e.target, entry.taskThreadId)
565
+ }, entry.title || 'Untitled'),
566
+ el('span', { className: 'group-tab-count' }, `${urls.length}`),
567
+ el('span', { className: 'archive-timestamp' }, entry.archivedAt ? timeAgo(new Date(entry.archivedAt).getTime()) : ''),
568
+ el('div', { className: 'group-actions' },
569
+ el('button', {
570
+ className: 'action-btn restore-btn',
571
+ title: 'Restore all tabs',
572
+ onClick: () => restoreGroupAction(entry.taskThreadId)
573
+ }, '↩'),
574
+ el('button', {
575
+ className: 'action-btn rename-btn',
576
+ title: 'Rename group',
577
+ onClick: (e) => {
578
+ const titleEl = header.querySelector('.archive-title');
579
+ if (titleEl) startRename(titleEl, entry.taskThreadId);
580
+ }
581
+ }, '✏️'),
582
+ el('button', {
583
+ className: 'action-btn delete-btn',
584
+ title: 'Delete archive',
585
+ onClick: () => deleteArchivedAction(entry.taskThreadId)
586
+ }, '🗑')
587
+ )
588
+ );
589
+
590
+ archiveNode.appendChild(header);
591
+ archiveNode.appendChild(tabList);
592
+ container.appendChild(archiveNode);
593
+ }
594
+
595
+ if (state.searchQuery) applySearchFilter();
596
+ }
597
+
598
+ // ─── Recently Closed Rendering (T022) ────────────────────────────────────────
599
+
600
+ async function fetchAndRenderRecent() {
601
+ try {
602
+ const sessions = await sendMessage('getRecentlyClosed', { maxResults: 25 });
603
+ state.recentlyClosed = sessions || [];
604
+ renderRecentlyClosed(state.recentlyClosed);
605
+ } catch (err) {
606
+ console.error('[Comet] Failed to fetch recently closed:', err);
607
+ state.recentlyClosed = [];
608
+ renderRecentlyClosed([]);
609
+ }
610
+ }
611
+
612
+ function renderRecentlyClosed(sessions) {
613
+ const container = DOM.recentContent;
614
+ const existingEmpty = DOM.recentEmpty;
615
+
616
+ // Clear existing
617
+ Array.from(container.children).forEach(child => {
618
+ if (child !== existingEmpty) child.remove();
619
+ });
620
+
621
+ DOM.recentCount.textContent = String(sessions.length);
622
+ existingEmpty.classList.toggle('hidden', sessions.length > 0);
623
+
624
+ for (const session of sessions) {
625
+ if (session.tab) {
626
+ const tab = session.tab;
627
+ const item = el('div', {
628
+ className: 'recent-item',
629
+ dataset: { sessionId: session.sessionId || '', title: tab.title || '', url: tab.url || '' }
630
+ },
631
+ el('span', { className: 'recent-type-icon' }, '📄'),
632
+ el('span', { className: 'tab-title', title: tab.title }, tab.title || 'Untitled'),
633
+ el('span', { className: 'tab-url' }, hostname(tab.url || '')),
634
+ el('span', { className: 'recent-timestamp' }, session.lastModified ? timeAgo(session.lastModified * 1000) : ''),
635
+ el('div', { className: 'group-actions' },
636
+ el('button', {
637
+ className: 'action-btn restore-btn',
638
+ title: 'Restore tab',
639
+ onClick: () => restoreClosedAction(session.sessionId)
640
+ }, '↩')
641
+ )
642
+ );
643
+ container.appendChild(item);
644
+ } else if (session.window) {
645
+ // T023: Expandable window entries in Recently Closed
646
+ const win = session.window;
647
+ const winTabs = win.tabs || [];
648
+ const tabCount = winTabs.length;
649
+ const windowKey = `recent-win-${session.sessionId}`;
650
+ const isExpanded = state.expandedGroups.has(windowKey);
651
+
652
+ const windowNode = el('div', {
653
+ className: 'group-node recent-window-node',
654
+ dataset: { sessionId: session.sessionId || '', title: `Window (${tabCount} tabs)` }
655
+ });
656
+
657
+ // Individual tabs list (expandable)
658
+ const tabList = el('div', {
659
+ className: `tab-list${isExpanded ? '' : ' collapsed'}`
660
+ });
661
+
662
+ for (const wTab of winTabs) {
663
+ tabList.appendChild(el('div', {
664
+ className: 'tab-item',
665
+ dataset: { title: wTab.title || '', url: wTab.url || '' }
666
+ },
667
+ wTab.favIconUrl
668
+ ? el('img', { className: 'tab-favicon', src: wTab.favIconUrl, alt: '' })
669
+ : el('span', { className: 'tab-favicon-placeholder' }, '●'),
670
+ el('span', { className: 'tab-title', title: wTab.title }, wTab.title || 'Untitled'),
671
+ el('span', { className: 'tab-url' }, hostname(wTab.url || ''))
672
+ ));
673
+ }
674
+
675
+ // Window header (clickable to expand/collapse)
676
+ const header = el('div', {
677
+ className: `group-header recent-item${isExpanded ? ' expanded' : ''}`,
678
+ onClick: (e) => {
679
+ if (e.target.closest('.action-btn')) return;
680
+ const nowExpanded = state.expandedGroups.has(windowKey);
681
+ if (nowExpanded) {
682
+ state.expandedGroups.delete(windowKey);
683
+ header.classList.remove('expanded');
684
+ tabList.classList.add('collapsed');
685
+ } else {
686
+ state.expandedGroups.add(windowKey);
687
+ header.classList.add('expanded');
688
+ tabList.classList.remove('collapsed');
689
+ }
690
+ }
691
+ },
692
+ el('span', { className: 'group-chevron' }, '▸'),
693
+ el('span', { className: 'recent-type-icon' }, '🪟'),
694
+ el('span', { className: 'tab-title' }, `Window (${tabCount} tabs)`),
695
+ el('span', { className: 'recent-timestamp' }, session.lastModified ? timeAgo(session.lastModified * 1000) : ''),
696
+ el('div', { className: 'group-actions' },
697
+ el('button', {
698
+ className: 'action-btn restore-btn',
699
+ title: 'Restore entire window',
700
+ onClick: () => restoreClosedAction(session.sessionId)
701
+ }, '↩')
702
+ )
703
+ );
704
+
705
+ windowNode.appendChild(header);
706
+ windowNode.appendChild(tabList);
707
+ container.appendChild(windowNode);
708
+ }
709
+ }
710
+
711
+ if (state.searchQuery) applySearchFilter();
712
+ }
713
+
714
+ // ─── Actions (T012, T019, T035) ──────────────────────────────────────────────
715
+
716
+ async function archiveGroupAction(groupId, title) {
717
+ try {
718
+ await sendMessage('archiveGroup', { groupId, title });
719
+ await Promise.all([fetchAndRenderLiveTree(), fetchAndRenderArchived()]);
720
+ } catch (err) {
721
+ console.error('[Comet] Archive failed:', err);
722
+ }
723
+ }
724
+
725
+ async function restoreGroupAction(taskThreadId) {
726
+ try {
727
+ await sendMessage('restoreGroup', { taskThreadId });
728
+ await Promise.all([fetchAndRenderLiveTree(), fetchAndRenderArchived()]);
729
+ } catch (err) {
730
+ console.error('[Comet] Restore failed:', err);
731
+ }
732
+ }
733
+
734
+ async function deleteArchivedAction(taskThreadId) {
735
+ try {
736
+ await sendMessage('deleteArchived', { taskThreadId });
737
+ await fetchAndRenderArchived();
738
+ } catch (err) {
739
+ console.error('[Comet] Delete archived failed:', err);
740
+ }
741
+ }
742
+
743
+ async function focusTabAction(tabId, windowId) {
744
+ try {
745
+ await sendMessage('focusTab', { tabId, windowId });
746
+ } catch (err) {
747
+ console.error('[Comet] Focus tab failed:', err);
748
+ }
749
+ }
750
+
751
+ async function closeTabAction(tabId) {
752
+ try {
753
+ await sendMessage('closeTab', { tabId });
754
+ await fetchAndRenderLiveTree();
755
+ } catch (err) {
756
+ console.error('[Comet] Close tab failed:', err);
757
+ }
758
+ }
759
+
760
+ async function closeGroupAction(groupId) {
761
+ try {
762
+ await sendMessage('closeGroup', { groupId });
763
+ await fetchAndRenderLiveTree();
764
+ } catch (err) {
765
+ console.error('[Comet] Close group failed:', err);
766
+ }
767
+ }
768
+
769
+ async function restoreClosedAction(sessionId) {
770
+ try {
771
+ await sendMessage('restoreClosed', { sessionId });
772
+ await Promise.all([fetchAndRenderLiveTree(), fetchAndRenderRecent()]);
773
+ } catch (err) {
774
+ console.error('[Comet] Restore closed failed:', err);
775
+ }
776
+ }
777
+
778
+ // ─── Save All Action ─────────────────────────────────────────────────────────
779
+
780
+ async function saveAllTabsAction() {
781
+ const btn = DOM.btnSaveAll;
782
+ const originalText = btn.textContent;
783
+ btn.disabled = true;
784
+ btn.textContent = ' Saving…';
785
+
786
+ try {
787
+ const result = await sendMessage('saveAllTabs');
788
+ btn.textContent = ` ✓ Saved ${result.totalTabs} tabs`;
789
+ setTimeout(() => {
790
+ btn.textContent = originalText;
791
+ btn.disabled = false;
792
+ }, 2000);
793
+ await Promise.all([fetchAndRenderLiveTree(), fetchAndRenderArchived()]);
794
+ } catch (err) {
795
+ console.error('[Comet] Save all failed:', err);
796
+ btn.textContent = originalText;
797
+ btn.disabled = false;
798
+ }
799
+ }
800
+
801
+ // ─── Rename Archive Action ───────────────────────────────────────────────────
802
+
803
+ function startRename(titleEl, taskThreadId) {
804
+ // Replace title span with an input
805
+ const currentTitle = titleEl.textContent;
806
+ const input = el('input', {
807
+ className: 'rename-input',
808
+ type: 'text',
809
+ value: currentTitle,
810
+ });
811
+
812
+ titleEl.style.display = 'none';
813
+ titleEl.parentElement.insertBefore(input, titleEl.nextSibling);
814
+ input.focus();
815
+ input.select();
816
+
817
+ const finishRename = async () => {
818
+ const newTitle = input.value.trim();
819
+ input.remove();
820
+ titleEl.style.display = '';
821
+
822
+ if (newTitle && newTitle !== currentTitle) {
823
+ try {
824
+ await sendMessage('renameArchived', { taskThreadId, newTitle });
825
+ titleEl.textContent = newTitle;
826
+ // Update in state
827
+ const entry = state.archived.find(e => e.taskThreadId === taskThreadId);
828
+ if (entry) entry.title = newTitle;
829
+ } catch (err) {
830
+ console.error('[Comet] Rename failed:', err);
831
+ }
832
+ }
833
+ };
834
+
835
+ input.addEventListener('blur', finishRename);
836
+ input.addEventListener('keydown', (e) => {
837
+ if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
838
+ if (e.key === 'Escape') { input.value = currentTitle; input.blur(); }
839
+ });
840
+ }
841
+
842
+ // ─── Single Tab Restore ──────────────────────────────────────────────────────
843
+
844
+ async function restoreSingleTabAction(taskThreadId, url) {
845
+ try {
846
+ await sendMessage('restoreSingleTab', { taskThreadId, url });
847
+ } catch (err) {
848
+ console.error('[Comet] Single tab restore failed:', err);
849
+ }
850
+ }
851
+
852
+ // ─── Import/Export (T029-T030, T037) ─────────────────────────────────────────
853
+
854
+ function initImportExport() {
855
+ // Save All button
856
+ DOM.btnSaveAll.addEventListener('click', saveAllTabsAction);
857
+
858
+ // Import button opens modal
859
+ DOM.btnImport.addEventListener('click', () => {
860
+ DOM.importOverlay.classList.remove('hidden');
861
+ DOM.importTextarea.value = '';
862
+ DOM.importPreview.classList.add('hidden');
863
+ DOM.importPreview.innerHTML = '';
864
+ DOM.importDuplicates.classList.add('hidden');
865
+ DOM.importConfirm.disabled = true;
866
+ DOM.importTextarea.focus();
867
+ });
868
+
869
+ // Close modal
870
+ const closeModal = () => DOM.importOverlay.classList.add('hidden');
871
+ DOM.importClose.addEventListener('click', closeModal);
872
+ DOM.importCancel.addEventListener('click', closeModal);
873
+ DOM.importOverlay.addEventListener('click', (e) => {
874
+ if (e.target === DOM.importOverlay) closeModal();
875
+ });
876
+
877
+ // Parse on input
878
+ DOM.importTextarea.addEventListener('input', () => {
879
+ const text = DOM.importTextarea.value.trim();
880
+ if (!text) {
881
+ DOM.importPreview.classList.add('hidden');
882
+ DOM.importConfirm.disabled = true;
883
+ return;
884
+ }
885
+ const parsed = parseOneTabExport(text);
886
+ renderImportPreview(parsed);
887
+ });
888
+
889
+ // Confirm import
890
+ DOM.importConfirm.addEventListener('click', async () => {
891
+ const groups = collectImportGroups();
892
+ if (!groups.length) return;
893
+
894
+ DOM.importConfirm.disabled = true;
895
+ DOM.importConfirm.textContent = 'Importing…';
896
+
897
+ try {
898
+ await sendMessage('importUrls', { groups });
899
+ closeModal();
900
+ await fetchAndRenderArchived();
901
+ } catch (err) {
902
+ console.error('[Comet] Import failed:', err);
903
+ } finally {
904
+ DOM.importConfirm.textContent = 'Import';
905
+ }
906
+ });
907
+
908
+ // Export button
909
+ DOM.btnExport.addEventListener('click', async () => {
910
+ try {
911
+ const data = await sendMessage('exportAll');
912
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
913
+ const url = URL.createObjectURL(blob);
914
+ const a = document.createElement('a');
915
+ a.href = url;
916
+ a.download = `comet-sessions-${new Date().toISOString().slice(0, 10)}.json`;
917
+ a.click();
918
+ URL.revokeObjectURL(url);
919
+ } catch (err) {
920
+ console.error('[Comet] Export failed:', err);
921
+ }
922
+ });
923
+ }
924
+
925
+ // ─── OneTab Parser (T029) ────────────────────────────────────────────────────
926
+
927
+ function parseOneTabExport(text) {
928
+ const lines = text.split('\n');
929
+ const groups = [];
930
+ let currentGroup = { name: '', urls: [] };
931
+
932
+ for (const line of lines) {
933
+ const trimmed = line.trim();
934
+ if (!trimmed) {
935
+ // Blank line = group separator
936
+ if (currentGroup.urls.length > 0) {
937
+ groups.push(currentGroup);
938
+ currentGroup = { name: '', urls: [] };
939
+ }
940
+ continue;
941
+ }
942
+
943
+ // Parse: URL | Title or just URL
944
+ const pipeIdx = trimmed.indexOf(' | ');
945
+ let url, title;
946
+ if (pipeIdx !== -1) {
947
+ url = trimmed.substring(0, pipeIdx).trim();
948
+ title = trimmed.substring(pipeIdx + 3).trim();
949
+ } else {
950
+ url = trimmed;
951
+ title = '';
952
+ }
953
+
954
+ // Basic URL validation
955
+ if (url.startsWith('http://') || url.startsWith('https://')) {
956
+ currentGroup.urls.push({ url, title });
957
+ }
958
+ }
959
+
960
+ // Push last group
961
+ if (currentGroup.urls.length > 0) {
962
+ groups.push(currentGroup);
963
+ }
964
+
965
+ // Auto-name unnamed groups
966
+ groups.forEach((g, i) => {
967
+ if (!g.name) {
968
+ g.name = `Imported Group ${i + 1}`;
969
+ }
970
+ });
971
+
972
+ return groups;
973
+ }
974
+
975
+ function renderImportPreview(groups) {
976
+ DOM.importPreview.innerHTML = '';
977
+
978
+ if (!groups.length) {
979
+ DOM.importPreview.classList.add('hidden');
980
+ DOM.importConfirm.disabled = true;
981
+ return;
982
+ }
983
+
984
+ DOM.importPreview.classList.remove('hidden');
985
+ DOM.importConfirm.disabled = false;
986
+
987
+ // Count duplicates across groups
988
+ const allUrls = groups.flatMap(g => g.urls.map(u => u.url));
989
+ const urlCounts = {};
990
+ allUrls.forEach(u => { urlCounts[u] = (urlCounts[u] || 0) + 1; });
991
+ const duplicateCount = Object.values(urlCounts).filter(c => c > 1).length;
992
+
993
+ if (duplicateCount > 0) {
994
+ DOM.importDuplicates.classList.remove('hidden');
995
+ DOM.importDuplicateText.textContent = `${duplicateCount} duplicate URL(s) detected across groups`;
996
+ } else {
997
+ DOM.importDuplicates.classList.add('hidden');
998
+ }
999
+
1000
+ for (const group of groups) {
1001
+ const groupEl = el('div', { className: 'import-preview-group' },
1002
+ el('div', { className: 'import-group-name' },
1003
+ el('input', { type: 'text', value: group.name, dataset: { groupName: 'true' } }),
1004
+ el('span', { className: 'import-group-count' }, `${group.urls.length} tabs`)
1005
+ )
1006
+ );
1007
+
1008
+ // Show first 3 URLs
1009
+ const showCount = Math.min(group.urls.length, 3);
1010
+ for (let i = 0; i < showCount; i++) {
1011
+ groupEl.appendChild(el('div', { className: 'import-group-url' }, group.urls[i].url));
1012
+ }
1013
+ if (group.urls.length > 3) {
1014
+ groupEl.appendChild(el('div', { className: 'import-group-url' }, `… and ${group.urls.length - 3} more`));
1015
+ }
1016
+
1017
+ DOM.importPreview.appendChild(groupEl);
1018
+ }
1019
+ }
1020
+
1021
+ function collectImportGroups() {
1022
+ const groups = [];
1023
+ const text = DOM.importTextarea.value.trim();
1024
+ if (!text) return groups;
1025
+
1026
+ const parsed = parseOneTabExport(text);
1027
+ const nameInputs = DOM.importPreview.querySelectorAll('input[data-group-name]');
1028
+
1029
+ parsed.forEach((group, i) => {
1030
+ const name = nameInputs[i] ? nameInputs[i].value.trim() : group.name;
1031
+ groups.push({ name: name || group.name, urls: group.urls });
1032
+ });
1033
+
1034
+ return groups;
1035
+ }
1036
+
1037
+ // ─── Auto-Refresh (T011) ─────────────────────────────────────────────────────
1038
+
1039
+ function startAutoRefresh() {
1040
+ // Poll every 3 seconds for changes
1041
+ state.refreshTimer = setInterval(async () => {
1042
+ await Promise.all([
1043
+ fetchAndRenderLiveTree(),
1044
+ fetchAndRenderArchived(),
1045
+ fetchAndRenderRecent(),
1046
+ ]);
1047
+ }, 3000);
1048
+
1049
+ // Health check every 10 seconds
1050
+ state.healthTimer = setInterval(checkHealth, 10000);
1051
+ }
1052
+
1053
+ function stopAutoRefresh() {
1054
+ if (state.refreshTimer) {
1055
+ clearInterval(state.refreshTimer);
1056
+ state.refreshTimer = null;
1057
+ }
1058
+ if (state.healthTimer) {
1059
+ clearInterval(state.healthTimer);
1060
+ state.healthTimer = null;
1061
+ }
1062
+ }
1063
+
1064
+ // ─── Reconnect Button ────────────────────────────────────────────────────────
1065
+
1066
+ function initReconnect() {
1067
+ DOM.btnReconnect.addEventListener('click', async () => {
1068
+ DOM.statusText.textContent = 'Reconnecting…';
1069
+ await checkHealth();
1070
+ });
1071
+ }
1072
+
1073
+ // ─── Keyboard Navigation (T039) ──────────────────────────────────────────────
1074
+
1075
+ function initKeyboardNav() {
1076
+ document.addEventListener('keydown', (e) => {
1077
+ // Don't intercept when typing in inputs/textareas
1078
+ const isInput = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA';
1079
+
1080
+ // Escape closes search or modal
1081
+ if (e.key === 'Escape') {
1082
+ if (!DOM.importOverlay.classList.contains('hidden')) {
1083
+ DOM.importOverlay.classList.add('hidden');
1084
+ } else if (DOM.searchInput.value) {
1085
+ DOM.searchInput.value = '';
1086
+ DOM.searchClear.classList.add('hidden');
1087
+ state.searchQuery = '';
1088
+ applySearchFilter();
1089
+ DOM.searchInput.blur();
1090
+ }
1091
+ return;
1092
+ }
1093
+
1094
+ // Ctrl/Cmd+F focuses search
1095
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
1096
+ e.preventDefault();
1097
+ DOM.searchInput.focus();
1098
+ DOM.searchInput.select();
1099
+ return;
1100
+ }
1101
+
1102
+ // Arrow key navigation only when not in an input field
1103
+ if (isInput) return;
1104
+
1105
+ // Gather all focusable tree items
1106
+ const focusable = [...document.querySelectorAll(
1107
+ '.group-header, .tab-item, .archive-item, .recent-item, .section-header'
1108
+ )].filter(el => el.offsetParent !== null); // only visible
1109
+
1110
+ if (!focusable.length) return;
1111
+
1112
+ const current = document.querySelector('.kb-focused');
1113
+ let idx = current ? focusable.indexOf(current) : -1;
1114
+
1115
+ if (e.key === 'ArrowDown') {
1116
+ e.preventDefault();
1117
+ idx = Math.min(idx + 1, focusable.length - 1);
1118
+ setKeyboardFocus(focusable, idx);
1119
+ } else if (e.key === 'ArrowUp') {
1120
+ e.preventDefault();
1121
+ idx = Math.max(idx - 1, 0);
1122
+ setKeyboardFocus(focusable, idx);
1123
+ } else if (e.key === 'Enter' && current) {
1124
+ e.preventDefault();
1125
+ current.click();
1126
+ } else if (e.key === 'Tab') {
1127
+ // Tab/Shift+Tab jumps between section headers
1128
+ e.preventDefault();
1129
+ const headers = [...document.querySelectorAll('.section-header')].filter(el => el.offsetParent !== null);
1130
+ if (!headers.length) return;
1131
+ const curHeader = current?.closest('.section')?.querySelector('.section-header');
1132
+ let hIdx = curHeader ? headers.indexOf(curHeader) : -1;
1133
+ hIdx = e.shiftKey
1134
+ ? (hIdx <= 0 ? headers.length - 1 : hIdx - 1)
1135
+ : (hIdx >= headers.length - 1 ? 0 : hIdx + 1);
1136
+ setKeyboardFocus(focusable, focusable.indexOf(headers[hIdx]));
1137
+ }
1138
+ });
1139
+ }
1140
+
1141
+ function setKeyboardFocus(items, idx) {
1142
+ // Remove previous focus
1143
+ document.querySelectorAll('.kb-focused').forEach(el => el.classList.remove('kb-focused'));
1144
+ if (idx >= 0 && idx < items.length) {
1145
+ items[idx].classList.add('kb-focused');
1146
+ items[idx].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1147
+ }
1148
+ }
1149
+
1150
+ // ─── Initialization ──────────────────────────────────────────────────────────
1151
+
1152
+ async function init() {
1153
+ // Load persisted state
1154
+ try {
1155
+ const stored = await chrome.storage.local.get(['expandedGroups', 'expandedSections']);
1156
+ if (stored.expandedGroups) {
1157
+ state.expandedGroups = new Set(stored.expandedGroups);
1158
+ }
1159
+ if (stored.expandedSections) {
1160
+ state.expandedSections = new Set(stored.expandedSections);
1161
+ }
1162
+ } catch {
1163
+ // Ignore storage errors
1164
+ }
1165
+
1166
+ // Initialize UI components
1167
+ initSectionToggles();
1168
+ initSearch();
1169
+ initImportExport();
1170
+ initReconnect();
1171
+ initKeyboardNav();
1172
+
1173
+ // Perform initial data fetch
1174
+ await checkHealth();
1175
+ await Promise.all([
1176
+ fetchAndRenderLiveTree(),
1177
+ fetchAndRenderArchived(),
1178
+ fetchAndRenderRecent(),
1179
+ ]);
1180
+
1181
+ // Start auto-refresh
1182
+ startAutoRefresh();
1183
+ }
1184
+
1185
+ // Start when DOM is ready
1186
+ document.addEventListener('DOMContentLoaded', init);