From 90834f2e5d1ce19697d9fced0f82d052c64674dc Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sun, 22 Mar 2026 11:47:13 +0700 Subject: [PATCH 01/12] fix(sidepanel): hide note bar on non-feed tabs Hide the "add note" text box when switching to Screenshots, History, and Export tabs in the side panel. The note bar now only appears on the Live Feed tab when actively recording. --- sidepanel/sidepanel.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 159bc3d..cd3eac0 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -19,6 +19,11 @@ $$('.tab').forEach(tab => { tab.classList.add('active'); $(`#tab-${tab.dataset.tab}`).classList.add('active'); $('#filters').classList.toggle('hidden', tab.dataset.tab !== 'feed'); + if (tab.dataset.tab !== 'feed') { + $('#note-bar').classList.add('hidden'); + } else if (activeSessionId) { + $('#note-bar').classList.remove('hidden'); + } if (tab.dataset.tab === 'history') loadHistory(); }); }); From a00bb274dd3ff753569b26cddb7b0174b0c8dfd8 Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sun, 22 Mar 2026 11:50:49 +0700 Subject: [PATCH 02/12] feat(sidepanel): add record/stop button to header Added Start/Stop recording button with visual state indicators: - "Record" button (red) displayed when idle - "Stop" button (gray) displayed when recording - State automatically syncs with recording session --- sidepanel/sidepanel.css | 11 +++++++++++ sidepanel/sidepanel.html | 1 + sidepanel/sidepanel.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 30ec501..e1c42df 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -28,6 +28,17 @@ header h1 { font-size: 14px; } animation: pulse 1.5s infinite; } +.btn-record { + background: #dc2626; + color: #fff; + border-color: #dc2626; +} + +.btn-record.recording { + background: #6b7280; + border-color: #6b7280; +} + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index d6a6cc1..53c9f1b 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -11,6 +11,7 @@

Debug Helper

+
Idle
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index cd3eac0..f06c33f 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -123,6 +123,8 @@ async function loadFeed() { noteBar.classList.add('hidden'); } + updateRecordButton(); + if (!currentSessionId) return; const sid = currentSessionId; @@ -144,6 +146,33 @@ async function loadFeed() { } } +// Toggle recording +$('#btn-record').addEventListener('click', async () => { + const btn = $('#btn-record'); + btn.disabled = true; + if (activeSessionId) { + await send({ type: 'session:stop' }); + } else { + await send({ type: 'session:start' }); + } + knownEventCount = -1; + loadFeed(); + btn.disabled = false; +}); + +function updateRecordButton() { + const btn = $('#btn-record'); + if (activeSessionId) { + btn.textContent = 'Stop'; + btn.title = 'Stop recording'; + btn.classList.add('recording'); + } else { + btn.textContent = 'Record'; + btn.title = 'Start recording'; + btn.classList.remove('recording'); + } +} + // Add note async function addNote() { const input = $('#note-input'); From 00b3942d783096b3e748e364c23d1eaa02b63b63 Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sun, 22 Mar 2026 11:53:04 +0700 Subject: [PATCH 03/12] feat(live-feed): add screenshot capture and thumbnail preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add capture button (๐Ÿ“ธ) in note bar during recording. Screenshot events now display clickable thumbnail previews that open the annotator. Visual styling includes blue highlight for screenshot event items in the feed. --- sidepanel/sidepanel.css | 19 +++++++++++++++++++ sidepanel/sidepanel.html | 1 + sidepanel/sidepanel.js | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index e1c42df..af1992b 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -113,6 +113,25 @@ header h1 { font-size: 14px; } border-left: 3px solid #f59e0b; } +.event-item.screenshot-event { + background: #eff6ff; + border-left: 3px solid #3b82f6; +} + +.feed-screenshot-thumb { + display: block; + max-width: 180px; + margin-top: 6px; + border-radius: var(--radius); + border: 1px solid var(--border); + cursor: pointer; + transition: opacity 0.15s; +} + +.feed-screenshot-thumb:hover { + opacity: 0.8; +} + .tab-content { display: none; flex: 1; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 53c9f1b..5552e0f 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -34,6 +34,7 @@

Debug Helper

diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index f06c33f..a1ac285 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -50,6 +50,7 @@ function badgeClass(type) { if (type === 'event:console') return 'badge-warn'; if (type.includes('network')) return 'badge-network'; if (type === 'event:note') return 'badge-note'; + if (type === 'event:screenshot') return 'badge-info'; return 'badge-info'; } @@ -66,6 +67,7 @@ function eventLabel(ev) { if (ev.type === 'event:console') return `${ev.level} ${escHtml(ev.message).slice(0, 200)}`; if (ev.type.includes('network')) return `${ev.method} ${escHtml(ev.url).slice(0, 100)} โ†’ ${ev.status} (${ev.duration}ms)`; if (ev.type === 'event:note') return `๐Ÿ“ ${escHtml(ev.content)}`; + if (ev.type === 'event:screenshot') return `๐Ÿ“ธ Screenshot captured`; return JSON.stringify(ev).slice(0, 200); } @@ -77,11 +79,31 @@ function escHtml(s) { function renderEvent(ev) { const div = document.createElement('div'); - div.className = 'event-item' + (ev.type === 'event:note' ? ' note-event' : ''); + div.className = 'event-item' + (ev.type === 'event:note' ? ' note-event' : '') + (ev.type === 'event:screenshot' ? ' screenshot-event' : ''); div.dataset.type = ev.type; const t = new Date(ev.timestamp); const time = t.toLocaleTimeString() + '.' + String(t.getMilliseconds()).padStart(3, '0'); div.innerHTML = `${time} ${ev.type.split(':').pop()}
${eventLabel(ev)}
`; + + // Load thumbnail for screenshot events + if (ev.type === 'event:screenshot' && ev.screenshotId) { + const thumb = document.createElement('img'); + thumb.className = 'feed-screenshot-thumb'; + thumb.title = 'Click to open annotator'; + thumb.addEventListener('click', () => { + chrome.windows.create({ + url: chrome.runtime.getURL(`annotator/annotator.html?id=${ev.screenshotId}`), + type: 'popup', width: 900, height: 700 + }); + }); + // Load image data async + send({ type: 'screenshot:list', sessionId: currentSessionId }).then(screenshots => { + const s = screenshots.find(sc => sc.id === ev.screenshotId); + if (s) thumb.src = s.annotatedDataUrl || s.dataUrl; + }); + div.appendChild(thumb); + } + return div; } @@ -189,6 +211,19 @@ $('#note-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') addNote(); }); +// Feed capture screenshot button +$('#btn-feed-capture').addEventListener('click', async () => { + const btn = $('#btn-feed-capture'); + btn.disabled = true; + btn.textContent = '...'; + await send({ type: 'screenshot:capture' }); + knownEventCount = -1; + loadFeed(); + loadScreenshots(); + btn.textContent = '๐Ÿ“ธ'; + btn.disabled = false; +}); + // Screenshots $('#btn-capture').addEventListener('click', async () => { await send({ type: 'screenshot:capture' }); From abaf09faeb625e6595c8464d9a4e86f0f6e58b2f Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sun, 22 Mar 2026 12:21:55 +0700 Subject: [PATCH 04/12] refactor(sidepanel): auto-scroll, screenshot sync, and error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace sort toggle with auto-scroll and pause button - Sync screenshot previews between Live Feed and Screenshots tab using shared cache - Fix note-bar appearing on non-feed tabs during recording - Add error handling to record/capture button handlers - Fix auto-scroll targeting wrong element (#feed โ†’ #tab-feed) - Change feed capture button from emoji to text label --- sidepanel/sidepanel.css | 16 ++++ sidepanel/sidepanel.html | 3 +- sidepanel/sidepanel.js | 172 ++++++++++++++++++++++++++------------- 3 files changed, 134 insertions(+), 57 deletions(-) diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index af1992b..7262708 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -87,6 +87,22 @@ header h1 { font-size: 14px; } border-color: var(--primary); } +.scroll-btn { + margin-left: auto; + padding: 2px 8px; + border: 1px solid var(--border); + border-radius: 12px; + background: none; + font-size: 10px; + cursor: pointer; +} + +.scroll-btn.paused { + background: #fef3c7; + border-color: #f59e0b; + color: #92400e; +} + .note-bar { display: flex; gap: 6px; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 5552e0f..06fde67 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -29,12 +29,13 @@

Debug Helper

+
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index a1ac285..3394708 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -2,6 +2,8 @@ const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); let activeFilter = 'all'; +let autoScroll = true; // auto-scroll feed to bottom +let cachedScreenshots = []; // shared screenshot cache for feed thumbnails let currentSessionId = null; // the session being viewed (from history or active) let activeSessionId = null; // the currently recording session (set by service worker) let viewingHistorical = false; // true when viewing a past session from history @@ -38,6 +40,18 @@ $$('.filter-btn').forEach(btn => { }); }); +// Auto-scroll pause toggle +$('#btn-autoscroll').addEventListener('click', () => { + autoScroll = !autoScroll; + const btn = $('#btn-autoscroll'); + btn.textContent = autoScroll ? 'Auto โ†“' : 'Paused'; + btn.classList.toggle('paused', !autoScroll); + if (autoScroll) { + const feed = $('#feed'); + feed.scrollTop = feed.scrollHeight; + } +}); + function applyFilter() { $$('.event-item').forEach(el => { if (activeFilter === 'all') { el.classList.remove('hidden'); return; } @@ -85,40 +99,46 @@ function renderEvent(ev) { const time = t.toLocaleTimeString() + '.' + String(t.getMilliseconds()).padStart(3, '0'); div.innerHTML = `${time} ${ev.type.split(':').pop()}
${eventLabel(ev)}
`; - // Load thumbnail for screenshot events + // Show thumbnail for screenshot events using cached data if (ev.type === 'event:screenshot' && ev.screenshotId) { - const thumb = document.createElement('img'); - thumb.className = 'feed-screenshot-thumb'; - thumb.title = 'Click to open annotator'; - thumb.addEventListener('click', () => { - chrome.windows.create({ - url: chrome.runtime.getURL(`annotator/annotator.html?id=${ev.screenshotId}`), - type: 'popup', width: 900, height: 700 + const s = cachedScreenshots.find(sc => sc.id === ev.screenshotId); + if (s) { + const thumb = document.createElement('img'); + thumb.className = 'feed-screenshot-thumb'; + thumb.dataset.screenshotId = ev.screenshotId; + thumb.title = 'Click to open annotator'; + thumb.src = s.annotatedDataUrl || s.dataUrl; + thumb.addEventListener('click', () => { + chrome.windows.create({ + url: chrome.runtime.getURL(`annotator/annotator.html?id=${ev.screenshotId}`), + type: 'popup', width: 900, height: 700 + }); }); - }); - // Load image data async - send({ type: 'screenshot:list', sessionId: currentSessionId }).then(screenshots => { - const s = screenshots.find(sc => sc.id === ev.screenshotId); - if (s) thumb.src = s.annotatedDataUrl || s.dataUrl; - }); - div.appendChild(thumb); + div.appendChild(thumb); + } } return div; } async function loadFeed() { - const state = await send({ type: 'session:get' }); + let state; + try { + state = await send({ type: 'session:get' }); + } catch { return; } // service worker unavailable const statusEl = $('#status'); const noteBar = $('#note-bar'); + // Check which tab is active โ€” only show note-bar on feed tab + const onFeedTab = document.querySelector('.tab[data-tab="feed"]')?.classList.contains('active'); + if (state.recording) { statusEl.textContent = 'Recording'; statusEl.className = 'status-badge recording'; activeSessionId = state.session.id; currentSessionId = state.session.id; viewingHistorical = false; - noteBar.classList.remove('hidden'); + if (onFeedTab) noteBar.classList.remove('hidden'); } else if (state.session) { noteBar.classList.add('hidden'); // Session exists (either just stopped or from lastSessionId) @@ -158,13 +178,30 @@ async function loadFeed() { } } + // Always refresh screenshot cache to pick up annotation edits + try { + cachedScreenshots = await send({ type: 'screenshot:list', sessionId: sid }) || []; + } catch { cachedScreenshots = []; } + if (events.length !== knownEventCount) { + events.sort((a, b) => a.timestamp - b.timestamp); const feed = $('#feed'); feed.innerHTML = ''; events.forEach(ev => feed.appendChild(renderEvent(ev))); - feed.scrollTop = feed.scrollHeight; + if (autoScroll) $('#tab-feed').scrollTop = $('#tab-feed').scrollHeight; knownEventCount = events.length; applyFilter(); + renderGallery(cachedScreenshots); + } else { + // Update existing feed thumbnails with latest screenshot data (e.g. after annotation) + $$('.feed-screenshot-thumb').forEach(thumb => { + const s = cachedScreenshots.find(sc => sc.id === thumb.dataset.screenshotId); + if (s) { + const newSrc = s.annotatedDataUrl || s.dataUrl; + if (thumb.src !== newSrc) thumb.src = newSrc; + } + }); + renderGallery(cachedScreenshots); } } @@ -172,14 +209,19 @@ async function loadFeed() { $('#btn-record').addEventListener('click', async () => { const btn = $('#btn-record'); btn.disabled = true; - if (activeSessionId) { - await send({ type: 'session:stop' }); - } else { - await send({ type: 'session:start' }); + try { + if (activeSessionId) { + await send({ type: 'session:stop' }); + } else { + await send({ type: 'session:start' }); + } + knownEventCount = -1; + loadFeed(); + } catch (err) { + console.error('[Debug Helper] Record toggle failed:', err); + } finally { + btn.disabled = false; } - knownEventCount = -1; - loadFeed(); - btn.disabled = false; }); function updateRecordButton() { @@ -216,23 +258,47 @@ $('#btn-feed-capture').addEventListener('click', async () => { const btn = $('#btn-feed-capture'); btn.disabled = true; btn.textContent = '...'; - await send({ type: 'screenshot:capture' }); - knownEventCount = -1; - loadFeed(); - loadScreenshots(); - btn.textContent = '๐Ÿ“ธ'; - btn.disabled = false; + try { + const result = await send({ type: 'screenshot:capture' }); + if (result?.error) { + btn.textContent = 'Failed'; + setTimeout(() => { btn.textContent = 'Capture'; }, 1500); + return; + } + knownEventCount = -1; + loadFeed(); + btn.textContent = 'Capture'; + } catch { + btn.textContent = 'Failed'; + setTimeout(() => { btn.textContent = 'Capture'; }, 1500); + } finally { + btn.disabled = false; + } }); // Screenshots $('#btn-capture').addEventListener('click', async () => { - await send({ type: 'screenshot:capture' }); - loadScreenshots(); + const btn = $('#btn-capture'); + btn.disabled = true; + try { + const result = await send({ type: 'screenshot:capture' }); + if (result?.error) { + btn.textContent = 'Failed'; + setTimeout(() => { btn.textContent = 'Capture Screenshot'; }, 1500); + return; + } + knownEventCount = -1; + loadFeed(); + } catch (err) { + btn.textContent = 'Failed'; + setTimeout(() => { btn.textContent = 'Capture Screenshot'; }, 1500); + } finally { + btn.disabled = false; + } }); -async function loadScreenshots() { - if (!currentSessionId) return; - const screenshots = await send({ type: 'screenshot:list', sessionId: currentSessionId }); +// Render gallery from screenshot array (shared by loadScreenshots and loadFeed) +function renderGallery(screenshots) { const gallery = $('#gallery'); gallery.innerHTML = ''; screenshots.forEach(s => { @@ -240,18 +306,21 @@ async function loadScreenshots() { img.src = s.annotatedDataUrl || s.dataUrl; img.title = new Date(s.timestamp).toLocaleString(); img.addEventListener('click', () => { - // Open annotator chrome.windows.create({ url: chrome.runtime.getURL(`annotator/annotator.html?id=${s.id}`), - type: 'popup', - width: 900, - height: 700 + type: 'popup', width: 900, height: 700 }); }); gallery.appendChild(img); }); } +async function loadScreenshots() { + if (!currentSessionId) return; + cachedScreenshots = await send({ type: 'screenshot:list', sessionId: currentSessionId }) || []; + renderGallery(cachedScreenshots); +} + // View a specific session (from history click) function viewSession(sessionId) { currentSessionId = sessionId; @@ -280,31 +349,22 @@ async function loadFeedForSession(sessionId) { } } + // Refresh screenshot cache before rendering + cachedScreenshots = await send({ type: 'screenshot:list', sessionId: sid }) || []; + events.sort((a, b) => a.timestamp - b.timestamp); const feed = $('#feed'); feed.innerHTML = ''; events.forEach(ev => feed.appendChild(renderEvent(ev))); - feed.scrollTop = feed.scrollHeight; + if (autoScroll) $('#tab-feed').scrollTop = $('#tab-feed').scrollHeight; knownEventCount = events.length; applyFilter(); + renderGallery(cachedScreenshots); } async function loadScreenshotsForSession(sessionId) { if (!sessionId) return; - const screenshots = await send({ type: 'screenshot:list', sessionId }); - const gallery = $('#gallery'); - gallery.innerHTML = ''; - screenshots.forEach(s => { - const img = document.createElement('img'); - img.src = s.annotatedDataUrl || s.dataUrl; - img.title = new Date(s.timestamp).toLocaleString(); - img.addEventListener('click', () => { - chrome.windows.create({ - url: chrome.runtime.getURL(`annotator/annotator.html?id=${s.id}`), - type: 'popup', width: 900, height: 700 - }); - }); - gallery.appendChild(img); - }); + cachedScreenshots = await send({ type: 'screenshot:list', sessionId }) || []; + renderGallery(cachedScreenshots); } // History From b58decaf0ce194acc43cb00fd34909f5eedfcde8 Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sun, 22 Mar 2026 12:52:57 +0700 Subject: [PATCH 05/12] feat: network body capture, expandable feed items, and event-driven updates - Capture request/response body for all network requests (fetch and XHR) - Add redaction for request body sensitive data - Add "Include req/res body" export option (default off) - Make feed items expandable on click to show full details - Add Pretty/Raw toggle and Copy buttons for JSON body content - Switch feed updates from polling to event-driven via chrome.storage.onChanged - Add lightweight session state polling (5s) separate from feed rendering - Fix renderBodyBlock to always return DOM element - Export markdown includes request/response body when enabled --- background/service-worker.js | 1 + content/network-capture.js | 49 ++++++--- lib/export.js | 24 +++-- sidepanel/sidepanel.css | 55 ++++++++++ sidepanel/sidepanel.html | 1 + sidepanel/sidepanel.js | 203 +++++++++++++++++++++++++++++++++-- 6 files changed, 302 insertions(+), 31 deletions(-) diff --git a/background/service-worker.js b/background/service-worker.js index fb7cebf..e2d8bc0 100644 --- a/background/service-worker.js +++ b/background/service-worker.js @@ -19,6 +19,7 @@ const Redact = { if (copy.message) copy.message = this.str(copy.message); if (copy.stack) copy.stack = this.str(copy.stack); if (copy.url) copy.url = this.str(copy.url); + if (copy.requestBody) copy.requestBody = this.str(copy.requestBody); if (copy.responseBody) copy.responseBody = this.str(copy.responseBody); if (copy.value) copy.value = this.str(copy.value); if (copy.content) copy.content = this.str(copy.content); diff --git a/content/network-capture.js b/content/network-capture.js index e03cbfc..4f989b0 100644 --- a/content/network-capture.js +++ b/content/network-capture.js @@ -16,23 +16,43 @@ // Wrap fetch const origFetch = window.fetch; + + function extractBody(body) { + if (!body) return null; + try { + if (typeof body === 'string') return body.slice(0, MAX_BODY); + if (body instanceof URLSearchParams) return body.toString().slice(0, MAX_BODY); + if (body instanceof FormData || body instanceof Blob || body instanceof ReadableStream || body instanceof ArrayBuffer) return null; + return JSON.stringify(body).slice(0, MAX_BODY); + } catch { return null; } + } + window.fetch = async function (input, init) { - const method = (init?.method || 'GET').toUpperCase(); + const isRequest = input instanceof Request; + const method = (init?.method || (isRequest ? input.method : 'GET')).toUpperCase(); const url = typeof input === 'string' ? input : input.url; const start = Date.now(); + // Capture request body for mutating methods + let requestBody = null; + if (/^(POST|PUT|PATCH|DELETE)$/.test(method)) { + requestBody = extractBody(init?.body) || (isRequest ? extractBody(input.body) : null); + } + try { const response = await origFetch.call(this, input, init); const duration = Date.now() - start; const entry = { timestamp: start, method, url, status: response.status, duration }; - if (response.status >= 400) { - try { - const clone = response.clone(); - const text = await clone.text(); - entry.responseBody = text.slice(0, MAX_BODY); - } catch {} - } + if (requestBody) entry.requestBody = requestBody; + + // Always capture response body + try { + const clone = response.clone(); + const text = await clone.text(); + if (text.length > 0) entry.responseBody = text.slice(0, MAX_BODY); + } catch {} + post(entry); return response; } catch (err) { @@ -53,17 +73,22 @@ XMLHttpRequest.prototype.send = function (body) { const start = Date.now(); + const method = (this.__dh_method || 'GET').toUpperCase(); + // Capture request body for mutating methods + let requestBody = null; + if (body && /^(POST|PUT|PATCH|DELETE)$/.test(method)) { + try { requestBody = typeof body === 'string' ? body.slice(0, MAX_BODY) : JSON.stringify(body).slice(0, MAX_BODY); } catch {} + } this.addEventListener('loadend', function () { const entry = { timestamp: start, - method: (this.__dh_method || 'GET').toUpperCase(), + method, url: this.__dh_url || '', status: this.status, duration: Date.now() - start }; - if (this.status >= 400) { - try { entry.responseBody = (this.responseText || '').slice(0, MAX_BODY); } catch {} - } + if (requestBody) entry.requestBody = requestBody; + try { const rt = (this.responseText || ''); if (rt.length > 0) entry.responseBody = rt.slice(0, MAX_BODY); } catch {} post(entry); }); return origSend.call(this, body); diff --git a/lib/export.js b/lib/export.js index daccb11..76b19d5 100644 --- a/lib/export.js +++ b/lib/export.js @@ -274,7 +274,12 @@ const Export = { status: e.status, duration: e.duration, }; - if (e.status >= 400 && e.responseBody) entry.responseBody = e.responseBody; + if (f.networkBody) { + if (e.requestBody) entry.requestBody = e.requestBody; + if (e.responseBody) entry.responseBody = e.responseBody; + } else if (e.status >= 400 && e.responseBody) { + entry.responseBody = e.responseBody; + } return entry; }); if (networkRequests.length > 0) report.networkRequests = networkRequests; @@ -361,15 +366,15 @@ const Export = { // Network โ€” only if non-empty if (r.networkRequests && r.networkRequests.length > 0) { + const renderBody = (e) => { + if (e.requestBody) { lines.push(' **Request:**'); lines.push(' ```'); lines.push(' ' + e.requestBody.slice(0, 2000)); lines.push(' ```'); } + if (e.responseBody) { lines.push(' **Response:**'); lines.push(' ```'); lines.push(' ' + e.responseBody.slice(0, 2000)); lines.push(' ```'); } + }; if (f.networkErrorsOnly) { lines.push('## Network Errors'); r.networkRequests.forEach(e => { lines.push(`- **${e.method} ${e.url}** โ†’ ${e.status} (${e.duration}ms)`); - if (e.responseBody) { - lines.push(' ```'); - lines.push(' ' + e.responseBody.slice(0, 2000)); - lines.push(' ```'); - } + renderBody(e); }); } else { const errors = r.networkRequests.filter(e => e.status >= 400 || e.status === 0); @@ -378,11 +383,7 @@ const Export = { lines.push('## Network Errors'); errors.forEach(e => { lines.push(`- **${e.method} ${e.url}** โ†’ ${e.status} (${e.duration}ms)`); - if (e.responseBody) { - lines.push(' ```'); - lines.push(' ' + e.responseBody.slice(0, 2000)); - lines.push(' ```'); - } + renderBody(e); }); lines.push(''); } @@ -390,6 +391,7 @@ const Export = { lines.push('## Network Requests'); ok.forEach(e => { lines.push(`- ${e.method} ${e.url} โ†’ ${e.status} (${e.duration}ms)`); + renderBody(e); }); lines.push(''); } diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 7262708..02485d2 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -173,12 +173,67 @@ header h1 { font-size: 14px; } font-family: monospace; } +.event-item { + cursor: pointer; +} + .event-item .detail { font-family: monospace; font-size: 11px; word-break: break-all; } +.event-item.expanded { + background: var(--bg-secondary); + border: 1px solid var(--border); +} + +.event-details { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid var(--border); + font-size: 11px; + line-height: 1.5; + word-break: break-all; +} + +.event-details.hidden { + display: none; +} + +.event-details pre { + margin: 2px 0 4px; + padding: 4px 6px; + background: rgba(0,0,0,0.04); + border-radius: 3px; + font-size: 10px; + white-space: pre-wrap; + word-break: break-all; + max-height: 200px; + overflow-y: auto; +} + +.body-actions { + display: inline-flex; + gap: 4px; + margin-left: 4px; +} + +.btn-body-toggle, +.btn-body-copy { + padding: 1px 6px; + font-size: 9px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg-secondary); + cursor: pointer; +} + +.btn-body-toggle:hover, +.btn-body-copy:hover { + background: var(--border); +} + .gallery { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 06fde67..547e8c5 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -70,6 +70,7 @@

Debug Helper

Network
+
Clean up
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 3394708..1cfcd03 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -91,6 +91,87 @@ function escHtml(s) { return d.innerHTML; } +// Render a body block with pretty/copy buttons for JSON content +function renderBodyBlock(label, body) { + if (!body) { + const empty = document.createElement('div'); + empty.innerHTML = `${label}: none`; + return empty; + } + + // Try to detect and pretty-print JSON + let prettyBody = null; + const trimmed = body.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { prettyBody = JSON.stringify(JSON.parse(trimmed), null, 2); } catch {} + } + + const container = document.createElement('div'); + container.innerHTML = `${label}:`; + + const actions = document.createElement('div'); + actions.className = 'body-actions'; + + const pre = document.createElement('pre'); + pre.textContent = (prettyBody || body).slice(0, 3000); + + if (prettyBody) { + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'btn-body-toggle'; + toggleBtn.textContent = 'Raw'; + toggleBtn._raw = body.slice(0, 3000); + toggleBtn._pretty = prettyBody.slice(0, 3000); + toggleBtn._pre = pre; + actions.appendChild(toggleBtn); + } + + const copyBtn = document.createElement('button'); + copyBtn.className = 'btn-body-copy'; + copyBtn.textContent = 'Copy'; + copyBtn._pre = pre; + actions.appendChild(copyBtn); + + container.appendChild(actions); + container.appendChild(pre); + return container; +} + +// Build expanded detail DOM element for an event +function buildEventDetails(ev) { + const container = document.createElement('div'); + + function addRow(html) { + const row = document.createElement('div'); + row.innerHTML = html; + container.appendChild(row); + } + + if (ev.type === 'event:dom') { + const ctx = ev.context || {}; + addRow(`Event: ${escHtml(ev.eventType)}`); + addRow(`Selector: ${escHtml(ev.selector)}`); + if (ctx.tag) addRow(`Tag: ${escHtml(ctx.tag)}`); + if (ctx.text) addRow(`Text: ${escHtml(ctx.text)}`); + if (ev.value) addRow(`Value: ${escHtml(ev.value)}`); + if (ctx.id) addRow(`ID: ${escHtml(ctx.id)}`); + if (ctx.className) addRow(`Class: ${escHtml(ctx.className)}`); + } else if (ev.type === 'event:console') { + addRow(`Level: ${escHtml(ev.level)}`); + addRow(`Message: ${escHtml(ev.message)}`); + if (ev.stack) addRow(`Stack:
${escHtml(ev.stack)}
`); + } else if (ev.type.includes('network')) { + addRow(`Method: ${escHtml(ev.method)}`); + addRow(`URL: ${escHtml(ev.url)}`); + addRow(`Status: ${ev.status} ยท Duration: ${ev.duration}ms`); + container.appendChild(renderBodyBlock('Request Body', ev.requestBody)); + container.appendChild(renderBodyBlock('Response Body', ev.responseBody)); + } else if (ev.type === 'event:note') { + addRow(`Note: ${escHtml(ev.content)}`); + } + addRow(`Time: ${new Date(ev.timestamp).toLocaleString()}`); + return container; +} + function renderEvent(ev) { const div = document.createElement('div'); div.className = 'event-item' + (ev.type === 'event:note' ? ' note-event' : '') + (ev.type === 'event:screenshot' ? ' screenshot-event' : ''); @@ -99,6 +180,38 @@ function renderEvent(ev) { const time = t.toLocaleTimeString() + '.' + String(t.getMilliseconds()).padStart(3, '0'); div.innerHTML = `${time} ${ev.type.split(':').pop()}
${eventLabel(ev)}
`; + // Expandable details on click (skip for screenshots โ€” thumbnail is already visible) + if (ev.type !== 'event:screenshot') { + const detailsDiv = document.createElement('div'); + detailsDiv.className = 'event-details hidden'; + detailsDiv.appendChild(buildEventDetails(ev)); + div.appendChild(detailsDiv); + + div.addEventListener('click', (e) => { + // Handle body action buttons + const toggleBtn = e.target.closest('.btn-body-toggle'); + if (toggleBtn) { + e.stopPropagation(); + const isRaw = toggleBtn.textContent === 'Raw'; + toggleBtn._pre.textContent = isRaw ? toggleBtn._raw : toggleBtn._pretty; + toggleBtn.textContent = isRaw ? 'Pretty' : 'Raw'; + return; + } + const copyBtn = e.target.closest('.btn-body-copy'); + if (copyBtn) { + e.stopPropagation(); + navigator.clipboard.writeText(copyBtn._pre.textContent).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1000); + }); + return; + } + // Toggle expand + div.classList.toggle('expanded'); + detailsDiv.classList.toggle('hidden'); + }); + } + // Show thumbnail for screenshot events using cached data if (ev.type === 'event:screenshot' && ev.screenshotId) { const s = cachedScreenshots.find(sc => sc.id === ev.screenshotId); @@ -108,7 +221,8 @@ function renderEvent(ev) { thumb.dataset.screenshotId = ev.screenshotId; thumb.title = 'Click to open annotator'; thumb.src = s.annotatedDataUrl || s.dataUrl; - thumb.addEventListener('click', () => { + thumb.addEventListener('click', (e) => { + e.stopPropagation(); chrome.windows.create({ url: chrome.runtime.getURL(`annotator/annotator.html?id=${ev.screenshotId}`), type: 'popup', width: 900, height: 700 @@ -186,8 +300,15 @@ async function loadFeed() { if (events.length !== knownEventCount) { events.sort((a, b) => a.timestamp - b.timestamp); const feed = $('#feed'); - feed.innerHTML = ''; - events.forEach(ev => feed.appendChild(renderEvent(ev))); + if (events.length > knownEventCount && knownEventCount > 0) { + // Append only new events to preserve expanded state + const newEvents = events.slice(knownEventCount); + newEvents.forEach(ev => feed.appendChild(renderEvent(ev))); + } else { + // Full re-render (first load, session switch, or events decreased) + feed.innerHTML = ''; + events.forEach(ev => feed.appendChild(renderEvent(ev))); + } if (autoScroll) $('#tab-feed').scrollTop = $('#tab-feed').scrollHeight; knownEventCount = events.length; applyFilter(); @@ -633,15 +754,80 @@ $('#btn-download').addEventListener('click', async () => { } }); -// Storage change listener for live updates +// Event-driven feed updates via storage change listener chrome.storage.onChanged.addListener((changes) => { - loadFeed(); - // Refresh history when a session is created or ended + const sid = currentSessionId; + + // Append new events to feed reactively (no full re-render) + if (sid) { + for (const key of Object.keys(changes)) { + if (key.startsWith('events:' + sid + ':') && changes[key].newValue) { + const newEvents = changes[key].newValue; + // Only process events we haven't seen (compare with oldValue) + const oldEvents = changes[key].oldValue || []; + const added = newEvents.slice(oldEvents.length); + if (added.length > 0) { + const feed = $('#feed'); + added.forEach(ev => { + feed.appendChild(renderEvent(ev)); + knownEventCount++; + }); + applyFilter(); + if (autoScroll) $('#tab-feed').scrollTop = $('#tab-feed').scrollHeight; + } + } + } + } + + // Refresh session state (recording status, screenshots) if (Object.keys(changes).some(k => k.startsWith('session:') || k === 'currentSessionId')) { + loadSessionState(); loadHistory(); } }); +// Lightweight session state update (no feed re-render) +async function loadSessionState() { + let state; + try { state = await send({ type: 'session:get' }); } catch { return; } + const statusEl = $('#status'); + const noteBar = $('#note-bar'); + const onFeedTab = document.querySelector('.tab[data-tab="feed"]')?.classList.contains('active'); + + if (state.recording) { + statusEl.textContent = 'Recording'; + statusEl.className = 'status-badge recording'; + activeSessionId = state.session.id; + if (currentSessionId !== state.session.id) { + currentSessionId = state.session.id; + viewingHistorical = false; + knownEventCount = 0; + $('#feed').innerHTML = ''; + } + if (onFeedTab) noteBar.classList.remove('hidden'); + } else if (state.session) { + noteBar.classList.add('hidden'); + if (activeSessionId && activeSessionId === state.session.id && state.session.endTime) { + statusEl.textContent = 'Session ended'; + statusEl.className = 'status-badge'; + activeSessionId = null; + loadHistory(); + } else if (!viewingHistorical) { + statusEl.textContent = state.session.endTime ? 'Last session' : 'Idle'; + statusEl.className = 'status-badge'; + } else { + statusEl.textContent = 'Viewing history'; + statusEl.className = 'status-badge'; + } + } else { + statusEl.textContent = viewingHistorical ? 'Viewing history' : 'Idle'; + statusEl.className = 'status-badge'; + activeSessionId = null; + noteBar.classList.add('hidden'); + } + updateRecordButton(); +} + // Initial load โ€” check if popup requested a specific session (async () => { const { viewSessionId } = await chrome.storage.local.get('viewSessionId'); @@ -654,7 +840,8 @@ chrome.storage.onChanged.addListener((changes) => { } loadHistory(); })(); +// Lightweight poll for screenshots and session state only setInterval(() => { - loadFeed(); + loadSessionState(); loadScreenshots(); -}, 3000); +}, 5000); From 25c3db17d8cee0fab7ca8bdd4cb04b227800b594 Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sun, 22 Mar 2026 12:57:16 +0700 Subject: [PATCH 06/12] feat(sidepanel): add screenshots filter and auto-scroll on recording start --- sidepanel/sidepanel.html | 1 + sidepanel/sidepanel.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 547e8c5..8ba16cf 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -29,6 +29,7 @@

Debug Helper

+
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 1cfcd03..228d2d6 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -803,6 +803,11 @@ async function loadSessionState() { viewingHistorical = false; knownEventCount = 0; $('#feed').innerHTML = ''; + // Auto-enable auto-scroll when new recording starts + autoScroll = true; + const scrollBtn = $('#btn-autoscroll'); + scrollBtn.textContent = 'Auto โ†“'; + scrollBtn.classList.remove('paused'); } if (onFeedTab) noteBar.classList.remove('hidden'); } else if (state.session) { From 05163fe05b0c98c00333c6ff533f3c6943ced3ec Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sun, 22 Mar 2026 13:03:12 +0700 Subject: [PATCH 07/12] perf: replace polling with event-driven updates and reduce feed latency - Remove 5s polling interval in sidepanel; now event-driven via chrome.storage.onChanged - Add session:flush handler in service worker for on-demand buffer flush - Implement 500ms debounced flush for faster feed appearance --- background/service-worker.js | 6 ++++++ sidepanel/sidepanel.js | 16 ++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/background/service-worker.js b/background/service-worker.js index e2d8bc0..ea854ab 100644 --- a/background/service-worker.js +++ b/background/service-worker.js @@ -31,6 +31,7 @@ const SW = { eventBuffer: [], FLUSH_INTERVAL: 2000, FLUSH_SIZE: 50, + _flushTimer: null, KEEPALIVE_NAME: 'debug-helper-keepalive', async init() { @@ -66,6 +67,7 @@ const SW = { case 'session:clear': return this.clearSession(msg.sessionId); case 'session:update': return this.updateSession(msg.sessionId, msg.updates); case 'session:list': return Storage.listSessions(); + case 'session:flush': return this.flushBuffer(); case 'screenshot:capture': return this.captureScreenshot(msg.tabId); case 'screenshot:save': return this.saveAnnotatedScreenshot(msg); case 'screenshot:list': return this.listScreenshots(msg.sessionId); @@ -198,6 +200,10 @@ const SW = { if (this.eventBuffer.length >= this.FLUSH_SIZE) { await this.flushBuffer(); + } else { + // Debounced flush โ€” write to storage 500ms after last event for snappy UI + clearTimeout(this._flushTimer); + this._flushTimer = setTimeout(() => this.flushBuffer(), 500); } return { buffered: true }; }, diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 228d2d6..20c1c45 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -365,8 +365,8 @@ async function addNote() { if (!text) return; input.value = ''; await send({ type: 'event:note', content: text, timestamp: Date.now() }); - knownEventCount = -1; // force refresh - loadFeed(); + // Flush buffer immediately so the note appears in storage right away + await send({ type: 'session:flush' }); } $('#btn-add-note').addEventListener('click', addNote); @@ -768,21 +768,26 @@ chrome.storage.onChanged.addListener((changes) => { const added = newEvents.slice(oldEvents.length); if (added.length > 0) { const feed = $('#feed'); + let hasScreenshot = false; added.forEach(ev => { feed.appendChild(renderEvent(ev)); knownEventCount++; + if (ev.type === 'event:screenshot') hasScreenshot = true; }); applyFilter(); if (autoScroll) $('#tab-feed').scrollTop = $('#tab-feed').scrollHeight; + // Refresh gallery when new screenshot events arrive + if (hasScreenshot) loadScreenshots(); } } } } - // Refresh session state (recording status, screenshots) + // Refresh session state on session changes if (Object.keys(changes).some(k => k.startsWith('session:') || k === 'currentSessionId')) { loadSessionState(); loadHistory(); + loadScreenshots(); } }); @@ -845,8 +850,3 @@ async function loadSessionState() { } loadHistory(); })(); -// Lightweight poll for screenshots and session state only -setInterval(() => { - loadSessionState(); - loadScreenshots(); -}, 5000); From a8d59b1bf348f0cc7e05d6cf4fee19fe88cfd069 Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sun, 22 Mar 2026 13:15:14 +0700 Subject: [PATCH 08/12] refactor(export): simplify network export and cache flush logic - Always include req/res body in network exports (removed networkBody filter gate) - Removed duplicate "Include req/res body" checkbox from export options - Quick Copy now uses export filter checkboxes instead of hardcoded filters - Notes flush immediately for instant feed appearance - Debounced 500ms flush for faster event feed updates --- lib/export.js | 8 ++------ sidepanel/sidepanel.html | 3 +-- sidepanel/sidepanel.js | 5 +---- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/lib/export.js b/lib/export.js index 76b19d5..41b5f04 100644 --- a/lib/export.js +++ b/lib/export.js @@ -274,12 +274,8 @@ const Export = { status: e.status, duration: e.duration, }; - if (f.networkBody) { - if (e.requestBody) entry.requestBody = e.requestBody; - if (e.responseBody) entry.responseBody = e.responseBody; - } else if (e.status >= 400 && e.responseBody) { - entry.responseBody = e.responseBody; - } + if (e.requestBody) entry.requestBody = e.requestBody; + if (e.responseBody) entry.responseBody = e.responseBody; return entry; }); if (networkRequests.length > 0) report.networkRequests = networkRequests; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 8ba16cf..4d33eec 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -71,8 +71,7 @@

Debug Helper

Network
- - +
Clean up
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 20c1c45..dbdac7f 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -659,10 +659,7 @@ $('#btn-quick-export').addEventListener('click', async () => { btn.textContent = 'Exporting...'; btn.disabled = true; try { - const result = await send({ type: 'export:generate', sessionId: sid, format: 'markdown', filters: { - steps: true, console: true, network: true, networkErrorsOnly: true, - screenshots: true, dedup: true, skipScrollZero: true, screenshotAsFile: true - }}); + const result = await send({ type: 'export:generate', sessionId: sid, format: 'markdown', filters: getExportFilters() }); if (result?.markdown) { try { await navigator.clipboard.writeText(result.markdown); } catch { /* fallback */ const ta = document.createElement('textarea'); ta.value = result.markdown; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } From ae33650d65c163538fd9e62accd5d6e863343dd6 Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sun, 22 Mar 2026 18:34:08 +0700 Subject: [PATCH 09/12] feat: add video recording with chrome.tabCapture API - Implement video capture using chrome.tabCapture - Rename Screenshots tab/filter to Media (images + videos) - Add inline video player with Open and Download buttons - Export videos in ZIP, JSON, and Markdown reports - Fix race condition: refresh cache before event write for preview - Fix video loss: defer stream cleanup until after onstop handler - Fix video storage: write to chrome.storage (bypass buffer) - Disable Add Note button when input empty - Show "Record first" on video button when no active session --- background/service-worker.js | 30 +++- lib/export.js | 43 +++++- manifest.json | 3 +- sidepanel/sidepanel.css | 46 ++++++ sidepanel/sidepanel.html | 8 +- sidepanel/sidepanel.js | 281 +++++++++++++++++++++++++++++++---- 6 files changed, 371 insertions(+), 40 deletions(-) diff --git a/background/service-worker.js b/background/service-worker.js index ea854ab..88545c3 100644 --- a/background/service-worker.js +++ b/background/service-worker.js @@ -69,6 +69,7 @@ const SW = { case 'session:list': return Storage.listSessions(); case 'session:flush': return this.flushBuffer(); case 'screenshot:capture': return this.captureScreenshot(msg.tabId); + case 'video:streamId': return this.getTabStreamId(msg.tabId); case 'screenshot:save': return this.saveAnnotatedScreenshot(msg); case 'screenshot:list': return this.listScreenshots(msg.sessionId); case 'storage:usage': return Storage.getStorageUsage(); @@ -81,6 +82,7 @@ const SW = { case 'event:network': case 'event:network:enhanced': case 'event:note': + case 'event:video': return this.bufferEvent(msg); default: return { error: 'Unknown message type: ' + msg.type }; @@ -260,6 +262,21 @@ const SW = { }); }, + async getTabStreamId(tabId) { + const session = await Storage.getCurrentSession(); + const tid = tabId || session?.tabId; + if (!tid) return { error: 'No active tab' }; + return new Promise((resolve) => { + chrome.tabCapture.getMediaStreamId({ targetTabId: tid }, (streamId) => { + if (chrome.runtime.lastError) { + resolve({ error: chrome.runtime.lastError.message }); + } else { + resolve({ streamId }); + } + }); + }); + }, + async listScreenshots(sessionId) { const sid = sessionId || (await Storage.getCurrentSession())?.id; if (!sid) return []; @@ -293,8 +310,18 @@ const SW = { entries.push({ name: sf.filename, data: new Uint8Array(buf) }); } - // Remove internal field before serializing + // Video files + const videoFiles = data.debugReport._videoFiles || []; + for (const vf of videoFiles) { + if (vf.blob) { + const buf = await vf.blob.arrayBuffer(); + entries.push({ name: vf.filename, data: new Uint8Array(buf) }); + } + } + + // Remove internal fields before serializing delete data.debugReport._screenshotFiles; + delete data.debugReport._videoFiles; // Report file if (format === 'markdown') { @@ -325,6 +352,7 @@ const SW = { // Remove internal _screenshotFiles before encoding const report = { ...data.debugReport }; delete report._screenshotFiles; + delete report._videoFiles; return { toon: Toon.encode({ debugReport: report }) }; } return { markdown: await Export.generateMarkdown(sessionId, filters) }; diff --git a/lib/export.js b/lib/export.js index 41b5f04..f8733df 100644 --- a/lib/export.js +++ b/lib/export.js @@ -156,11 +156,15 @@ const Export = { if (f.dedup) events = this.deduplicateClicks(events); if (f.skipScrollZero) events = this.filterScrollZero(events); + // Split media into screenshots and videos + const imageMedia = screenshots.filter(s => !s.mediaType || s.mediaType !== 'video'); + const videoMedia = screenshots.filter(s => s.mediaType === 'video'); + // Build screenshot lookup const screenshotMap = {}; const screenshotEntries = []; let ssIdx = 0; - for (const s of screenshots) { + for (const s of imageMedia) { ssIdx++; const src = s.annotatedDataUrl || s.dataUrl; const compressed = await this.compressScreenshot(src); @@ -178,6 +182,20 @@ const Export = { screenshotEntries.push(entry); } + // Build video entries + const videoEntries = []; + let vidIdx = 0; + for (const v of videoMedia) { + vidIdx++; + videoEntries.push({ + id: v.id, + index: vidIdx, + timestamp: new Date(v.timestamp).toISOString(), + filename: `video-${vidIdx}.webm`, + _blob: v.videoBlob || null, + }); + } + // Build unified timeline const timeline = []; let stepNum = 0; @@ -301,6 +319,19 @@ const Export = { } } + // Videos + if (f.screenshots && videoEntries.length > 0) { + report.videos = videoEntries.map(v => ({ + id: v.id, + index: v.index, + timestamp: v.timestamp, + filename: v.filename, + })); + report._videoFiles = videoEntries + .filter(v => v._blob) + .map(v => ({ filename: v.filename, blob: v._blob })); + } + return { debugReport: report }; }, @@ -415,6 +446,16 @@ const Export = { }); } + // Videos โ€” only if non-empty + if (r.videos && r.videos.length > 0) { + lines.push('## Videos'); + r.videos.forEach(v => { + lines.push(`### Video ${v.index} (${v.timestamp})`); + lines.push(`- File: [${v.filename}](./${v.filename})`); + lines.push(''); + }); + } + return lines.join('\n'); } }; diff --git a/manifest.json b/manifest.json index a84a255..fc3bd27 100644 --- a/manifest.json +++ b/manifest.json @@ -9,7 +9,8 @@ "storage", "tabs", "sidePanel", - "alarms" + "alarms", + "tabCapture" ], "background": { "service_worker": "background/service-worker.js" diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 02485d2..caa60e9 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -39,6 +39,18 @@ header h1 { font-size: 14px; } border-color: #6b7280; } +.btn-video { + background: none; + border-color: var(--border); +} + +.btn-video.recording-video { + background: #dc2626; + color: #fff; + border-color: #dc2626; + animation: pulse 1.5s infinite; +} + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } @@ -134,6 +146,32 @@ header h1 { font-size: 14px; } border-left: 3px solid #3b82f6; } +.event-item.video-event { + background: #faf5ff; + border-left: 3px solid #8b5cf6; +} + +.gallery-video { + display: flex; + flex-direction: column; + gap: 4px; +} + +.gallery-video video { + width: 100%; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.gallery-video-actions { + display: flex; + gap: 4px; +} + +.gallery-video-actions .btn { + font-size: 10px; +} + .feed-screenshot-thumb { display: block; max-width: 180px; @@ -148,6 +186,14 @@ header h1 { font-size: 14px; } opacity: 0.8; } +.feed-video-thumb { + display: block; + max-width: 240px; + margin-top: 6px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + .tab-content { display: none; flex: 1; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 4d33eec..7e6ad4f 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -18,7 +18,7 @@

Debug Helper

@@ -29,7 +29,7 @@

Debug Helper

- +
@@ -37,6 +37,7 @@

Debug Helper

+
@@ -44,9 +45,6 @@

Debug Helper

-
- -
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index dbdac7f..dfd22f7 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -55,7 +55,12 @@ $('#btn-autoscroll').addEventListener('click', () => { function applyFilter() { $$('.event-item').forEach(el => { if (activeFilter === 'all') { el.classList.remove('hidden'); return; } - el.classList.toggle('hidden', el.dataset.type !== activeFilter); + if (activeFilter === 'media') { + // Media filter: show screenshots and video notes + el.classList.toggle('hidden', el.dataset.type !== 'event:screenshot' && el.dataset.type !== 'event:video'); + } else { + el.classList.toggle('hidden', el.dataset.type !== activeFilter); + } }); } @@ -65,6 +70,7 @@ function badgeClass(type) { if (type.includes('network')) return 'badge-network'; if (type === 'event:note') return 'badge-note'; if (type === 'event:screenshot') return 'badge-info'; + if (type === 'event:video') return 'badge-info'; return 'badge-info'; } @@ -82,6 +88,7 @@ function eventLabel(ev) { if (ev.type.includes('network')) return `${ev.method} ${escHtml(ev.url).slice(0, 100)} โ†’ ${ev.status} (${ev.duration}ms)`; if (ev.type === 'event:note') return `๐Ÿ“ ${escHtml(ev.content)}`; if (ev.type === 'event:screenshot') return `๐Ÿ“ธ Screenshot captured`; + if (ev.type === 'event:video') return `๐ŸŽฅ ${escHtml(ev.content)}`; return JSON.stringify(ev).slice(0, 200); } @@ -167,6 +174,8 @@ function buildEventDetails(ev) { container.appendChild(renderBodyBlock('Response Body', ev.responseBody)); } else if (ev.type === 'event:note') { addRow(`Note: ${escHtml(ev.content)}`); + } else if (ev.type === 'event:video') { + addRow(`Video: ${escHtml(ev.content)}`); } addRow(`Time: ${new Date(ev.timestamp).toLocaleString()}`); return container; @@ -174,7 +183,7 @@ function buildEventDetails(ev) { function renderEvent(ev) { const div = document.createElement('div'); - div.className = 'event-item' + (ev.type === 'event:note' ? ' note-event' : '') + (ev.type === 'event:screenshot' ? ' screenshot-event' : ''); + div.className = 'event-item' + (ev.type === 'event:note' ? ' note-event' : '') + (ev.type === 'event:screenshot' ? ' screenshot-event' : '') + (ev.type === 'event:video' ? ' video-event' : ''); div.dataset.type = ev.type; const t = new Date(ev.timestamp); const time = t.toLocaleTimeString() + '.' + String(t.getMilliseconds()).padStart(3, '0'); @@ -232,6 +241,21 @@ function renderEvent(ev) { } } + // Show video preview for video events + if (ev.type === 'event:video' && ev.videoId) { + const v = cachedScreenshots.find(sc => sc.id === ev.videoId); + if (v && v.videoBlob) { + const video = document.createElement('video'); + video.className = 'feed-video-thumb'; + video.src = URL.createObjectURL(v.videoBlob); + video.controls = true; + video.preload = 'metadata'; + video.title = 'Click to play'; + video.addEventListener('click', (e) => e.stopPropagation()); + div.appendChild(video); + } + } + return div; } @@ -332,6 +356,8 @@ $('#btn-record').addEventListener('click', async () => { btn.disabled = true; try { if (activeSessionId) { + // Stop video recording if active โ€” wait for save to complete before ending session + if (videoRecorder && videoRecorder.state === 'recording') await stopVideoRecording(); await send({ type: 'session:stop' }); } else { await send({ type: 'session:start' }); @@ -370,6 +396,10 @@ async function addNote() { } $('#btn-add-note').addEventListener('click', addNote); +$('#btn-add-note').disabled = true; +$('#note-input').addEventListener('input', () => { + $('#btn-add-note').disabled = !$('#note-input').value.trim(); +}); $('#note-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') addNote(); }); @@ -397,48 +427,232 @@ $('#btn-feed-capture').addEventListener('click', async () => { } }); -// Screenshots -$('#btn-capture').addEventListener('click', async () => { - const btn = $('#btn-capture'); - btn.disabled = true; +// Video recording +let videoRecorder = null; +let videoChunks = []; +let videoStream = null; +let videoSessionId = null; // capture session ID at recording start + +async function startVideoRecording() { + const btn = $('#btn-video'); + if (!activeSessionId) { + btn.textContent = 'Record first'; + setTimeout(() => { btn.textContent = 'Video'; }, 1500); + return; + } try { - const result = await send({ type: 'screenshot:capture' }); + const result = await send({ type: 'video:streamId' }); if (result?.error) { btn.textContent = 'Failed'; - setTimeout(() => { btn.textContent = 'Capture Screenshot'; }, 1500); + setTimeout(() => { btn.textContent = 'Video'; }, 1500); return; } - knownEventCount = -1; - loadFeed(); + videoStream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + mandatory: { + chromeMediaSource: 'tab', + chromeMediaSourceId: result.streamId, + } + } + }); + videoChunks = []; + videoSessionId = currentSessionId; + videoRecorder = new MediaRecorder(videoStream, { mimeType: 'video/webm;codecs=vp9' }); + videoRecorder.ondataavailable = (e) => { + if (e.data.size > 0) videoChunks.push(e.data); + }; + videoRecorder.onstop = async () => { + const blob = new Blob(videoChunks, { type: 'video/webm' }); + videoChunks = []; + const videoId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + // Save video blob directly to IndexedDB from sidepanel + try { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('debug-helper', 1); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction('screenshots', 'readwrite'); + tx.objectStore('screenshots').put({ + id: videoId, + sessionId: videoSessionId, + mediaType: 'video', + videoBlob: blob, + timestamp: Date.now() + }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch (err) { + console.error('[Debug Helper] Failed to save video:', err); + } + // Refresh cache BEFORE writing event so renderEvent can find the video blob + const sid = videoSessionId; + if (sid) { + cachedScreenshots = await getMediaFromDB(sid); + renderGallery(cachedScreenshots); + + const videoEvent = { + type: 'event:video', + content: `Video recorded (${(blob.size / 1024 / 1024).toFixed(1)} MB)`, + videoId, + timestamp: Date.now(), + _sessionId: sid + }; + // Write directly to chrome.storage (session may already be stopped, buffer won't work) + const all = await chrome.storage.local.get(null); + let lastChunk = 0; + for (const k in all) { + if (k.startsWith('events:' + sid + ':')) { + const idx = parseInt(k.split(':')[2], 10); + if (idx > lastChunk) lastChunk = idx; + } + } + const chunkKey = `events:${sid}:${lastChunk}`; + const existing = all[chunkKey] || []; + existing.push(videoEvent); + await chrome.storage.local.set({ [chunkKey]: existing }); + } + }; + videoRecorder.start(1000); // collect in 1s chunks + btn.textContent = 'Stop'; + btn.classList.add('recording-video'); } catch (err) { + console.error('[Debug Helper] Video recording failed:', err); btn.textContent = 'Failed'; - setTimeout(() => { btn.textContent = 'Capture Screenshot'; }, 1500); - } finally { - btn.disabled = false; + setTimeout(() => { btn.textContent = 'Video'; }, 1500); + } +} + +// Returns a promise that resolves after onstop handler completes +function stopVideoRecording() { + const btn = $('#btn-video'); + btn.textContent = 'Saving...'; + btn.classList.remove('recording-video'); + return new Promise((resolve) => { + if (videoRecorder && videoRecorder.state !== 'inactive') { + const origOnStop = videoRecorder.onstop; + videoRecorder.onstop = async (e) => { + // Run original handler first (saves blob + event) + if (origOnStop) await origOnStop(e); + // Clean up stream and recorder AFTER save completes + if (videoStream) { + videoStream.getTracks().forEach(t => t.stop()); + videoStream = null; + } + videoRecorder = null; + btn.textContent = 'Video'; + resolve(); + }; + videoRecorder.stop(); + } else { + if (videoStream) { + videoStream.getTracks().forEach(t => t.stop()); + videoStream = null; + } + videoRecorder = null; + btn.textContent = 'Video'; + resolve(); + } + }); +} + +$('#btn-video').addEventListener('click', () => { + if (videoRecorder && videoRecorder.state === 'recording') { + stopVideoRecording(); + } else { + startVideoRecording(); } }); -// Render gallery from screenshot array (shared by loadScreenshots and loadFeed) -function renderGallery(screenshots) { +// Render gallery from media array (screenshots + videos) +function renderGallery(mediaItems) { const gallery = $('#gallery'); gallery.innerHTML = ''; - screenshots.forEach(s => { - const img = document.createElement('img'); - img.src = s.annotatedDataUrl || s.dataUrl; - img.title = new Date(s.timestamp).toLocaleString(); - img.addEventListener('click', () => { - chrome.windows.create({ - url: chrome.runtime.getURL(`annotator/annotator.html?id=${s.id}`), - type: 'popup', width: 900, height: 700 + mediaItems.forEach(s => { + if (s.mediaType === 'video' && s.videoBlob) { + // Video item + const wrapper = document.createElement('div'); + wrapper.className = 'gallery-video'; + const video = document.createElement('video'); + video.src = URL.createObjectURL(s.videoBlob); + video.controls = true; + video.preload = 'metadata'; + video.title = new Date(s.timestamp).toLocaleString(); + // Action buttons + const actions = document.createElement('div'); + actions.className = 'gallery-video-actions'; + // Open in popup + const openBtn = document.createElement('button'); + openBtn.className = 'btn btn-sm btn-primary'; + openBtn.textContent = 'Open'; + openBtn.addEventListener('click', () => { + const blobUrl = URL.createObjectURL(s.videoBlob); + const w = window.open('', '_blank', 'width=900,height=700'); + w.document.title = 'Debug Helper - Video'; + w.document.body.style.cssText = 'margin:0;background:#000;display:flex;align-items:center;justify-content:center;height:100vh'; + const v = w.document.createElement('video'); + v.src = blobUrl; + v.controls = true; + v.autoplay = true; + v.style.maxWidth = '100%'; + v.style.maxHeight = '100%'; + w.document.body.appendChild(v); }); - }); - gallery.appendChild(img); + // Download + const dlBtn = document.createElement('button'); + dlBtn.className = 'btn btn-sm'; + dlBtn.textContent = 'Download'; + dlBtn.addEventListener('click', () => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(s.videoBlob); + const ts = new Date(s.timestamp).toISOString().replace(/[:.]/g, '-').slice(0, 19); + a.download = `debug-video-${ts}.webm`; + a.click(); + }); + actions.appendChild(openBtn); + actions.appendChild(dlBtn); + wrapper.appendChild(video); + wrapper.appendChild(actions); + gallery.appendChild(wrapper); + } else { + // Screenshot item + const img = document.createElement('img'); + img.src = s.annotatedDataUrl || s.dataUrl; + img.title = new Date(s.timestamp).toLocaleString(); + img.addEventListener('click', () => { + chrome.windows.create({ + url: chrome.runtime.getURL(`annotator/annotator.html?id=${s.id}`), + type: 'popup', width: 900, height: 700 + }); + }); + gallery.appendChild(img); + } + }); +} + +// Read media directly from IndexedDB (blobs can't survive chrome.runtime.sendMessage) +async function getMediaFromDB(sessionId) { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('debug-helper', 1); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + return new Promise((resolve, reject) => { + const tx = db.transaction('screenshots', 'readonly'); + const req = tx.objectStore('screenshots').getAll(); + req.onsuccess = () => { + resolve(req.result.filter(s => s.sessionId === sessionId).sort((a, b) => a.timestamp - b.timestamp)); + }; + req.onerror = () => reject(req.error); }); } async function loadScreenshots() { if (!currentSessionId) return; - cachedScreenshots = await send({ type: 'screenshot:list', sessionId: currentSessionId }) || []; + cachedScreenshots = await getMediaFromDB(currentSessionId); renderGallery(cachedScreenshots); } @@ -470,8 +684,8 @@ async function loadFeedForSession(sessionId) { } } - // Refresh screenshot cache before rendering - cachedScreenshots = await send({ type: 'screenshot:list', sessionId: sid }) || []; + // Refresh media cache before rendering (read directly from IndexedDB to preserve blobs) + cachedScreenshots = await getMediaFromDB(sid); events.sort((a, b) => a.timestamp - b.timestamp); const feed = $('#feed'); feed.innerHTML = ''; @@ -484,7 +698,7 @@ async function loadFeedForSession(sessionId) { async function loadScreenshotsForSession(sessionId) { if (!sessionId) return; - cachedScreenshots = await send({ type: 'screenshot:list', sessionId }) || []; + cachedScreenshots = await getMediaFromDB(sessionId); renderGallery(cachedScreenshots); } @@ -635,7 +849,10 @@ async function generatePreview(format) { } else { // Strip internal fields from preview/copy const clean = JSON.parse(JSON.stringify(result)); - if (clean.debugReport) delete clean.debugReport._screenshotFiles; + if (clean.debugReport) { + delete clean.debugReport._screenshotFiles; + delete clean.debugReport._videoFiles; + } lastExportText = JSON.stringify(clean, null, 2); lastExportFormat = 'json'; } @@ -769,11 +986,11 @@ chrome.storage.onChanged.addListener((changes) => { added.forEach(ev => { feed.appendChild(renderEvent(ev)); knownEventCount++; - if (ev.type === 'event:screenshot') hasScreenshot = true; + if (ev.type === 'event:screenshot' || ev.type === 'event:video') hasScreenshot = true; }); applyFilter(); if (autoScroll) $('#tab-feed').scrollTop = $('#tab-feed').scrollHeight; - // Refresh gallery when new screenshot events arrive + // Refresh gallery when new media events arrive if (hasScreenshot) loadScreenshots(); } } From c31881714a2a09bfdab309837960014ab66e8f1e Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sat, 28 Mar 2026 13:12:45 +0700 Subject: [PATCH 10/12] fix: prevent media override by eliminating storage race condition Video onstop handler was reading all storage as a stale snapshot then writing back, overwriting events flushed concurrently by the service worker. Now sends video events through the buffer first, with a fresh- read fallback. Also switched loadFeed to read media directly from IndexedDB so video blobs survive (message passing strips them). --- sidepanel/sidepanel.js | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index dfd22f7..d8a19b0 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -317,8 +317,9 @@ async function loadFeed() { } // Always refresh screenshot cache to pick up annotation edits + // Read directly from IndexedDB to preserve video blobs (can't survive message passing) try { - cachedScreenshots = await send({ type: 'screenshot:list', sessionId: sid }) || []; + cachedScreenshots = await getMediaFromDB(sid); } catch { cachedScreenshots = []; } if (events.length !== knownEventCount) { @@ -501,19 +502,29 @@ async function startVideoRecording() { timestamp: Date.now(), _sessionId: sid }; - // Write directly to chrome.storage (session may already be stopped, buffer won't work) - const all = await chrome.storage.local.get(null); - let lastChunk = 0; - for (const k in all) { - if (k.startsWith('events:' + sid + ':')) { - const idx = parseInt(k.split(':')[2], 10); - if (idx > lastChunk) lastChunk = idx; + // Try sending through service worker buffer first (avoids race with flushBuffer) + let buffered = false; + try { + const result = await send(videoEvent); + buffered = result && result.buffered; + } catch { /* service worker unavailable */ } + // Fallback: write directly if buffer didn't accept (session already stopped) + if (!buffered) { + const allKeys = await chrome.storage.local.get(null); + let lastChunk = 0; + for (const k in allKeys) { + if (k.startsWith('events:' + sid + ':')) { + const idx = parseInt(k.split(':')[2], 10); + if (idx > lastChunk) lastChunk = idx; + } } + const chunkKey = `events:${sid}:${lastChunk}`; + // Fresh read of just this chunk to minimize race window + const freshData = await chrome.storage.local.get(chunkKey); + const existing = freshData[chunkKey] || []; + existing.push(videoEvent); + await chrome.storage.local.set({ [chunkKey]: existing }); } - const chunkKey = `events:${sid}:${lastChunk}`; - const existing = all[chunkKey] || []; - existing.push(videoEvent); - await chrome.storage.local.set({ [chunkKey]: existing }); } }; videoRecorder.start(1000); // collect in 1s chunks From abf18d6a230966fc0d0e51658d1803d769814cf3 Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Sat, 28 Mar 2026 13:14:32 +0700 Subject: [PATCH 11/12] fix(sidepanel): video feed items open in popup viewer on click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match screenshot behavior โ€” clicking video thumbnail opens a popup player instead of expanding inline details. Removed inline controls and added hover/cursor styles consistent with screenshot thumbnails. --- sidepanel/sidepanel.css | 6 ++++++ sidepanel/sidepanel.js | 24 ++++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index caa60e9..e26ac03 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -192,6 +192,12 @@ header h1 { font-size: 14px; } margin-top: 6px; border-radius: var(--radius); border: 1px solid var(--border); + cursor: pointer; + transition: opacity 0.15s; +} + +.feed-video-thumb:hover { + opacity: 0.8; } .tab-content { diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index d8a19b0..8c78ca6 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -189,8 +189,8 @@ function renderEvent(ev) { const time = t.toLocaleTimeString() + '.' + String(t.getMilliseconds()).padStart(3, '0'); div.innerHTML = `${time} ${ev.type.split(':').pop()}
${eventLabel(ev)}
`; - // Expandable details on click (skip for screenshots โ€” thumbnail is already visible) - if (ev.type !== 'event:screenshot') { + // Expandable details on click (skip for screenshots/videos โ€” thumbnail is already visible) + if (ev.type !== 'event:screenshot' && ev.type !== 'event:video') { const detailsDiv = document.createElement('div'); detailsDiv.className = 'event-details hidden'; detailsDiv.appendChild(buildEventDetails(ev)); @@ -241,17 +241,29 @@ function renderEvent(ev) { } } - // Show video preview for video events + // Show video thumbnail for video events โ€” click opens in popup viewer if (ev.type === 'event:video' && ev.videoId) { const v = cachedScreenshots.find(sc => sc.id === ev.videoId); if (v && v.videoBlob) { const video = document.createElement('video'); video.className = 'feed-video-thumb'; video.src = URL.createObjectURL(v.videoBlob); - video.controls = true; video.preload = 'metadata'; - video.title = 'Click to play'; - video.addEventListener('click', (e) => e.stopPropagation()); + video.title = 'Click to open video'; + video.addEventListener('click', (e) => { + e.stopPropagation(); + const blobUrl = URL.createObjectURL(v.videoBlob); + const w = window.open('', '_blank', 'width=900,height=700'); + w.document.title = 'Debug Helper - Video'; + w.document.body.style.cssText = 'margin:0;background:#000;display:flex;align-items:center;justify-content:center;height:100vh'; + const player = w.document.createElement('video'); + player.src = blobUrl; + player.controls = true; + player.autoplay = true; + player.style.maxWidth = '100%'; + player.style.maxHeight = '100%'; + w.document.body.appendChild(player); + }); div.appendChild(video); } } From dea6c1b7007acb974f7889d35a3cf1439e3ba7be Mon Sep 17 00:00:00 2001 From: "huyqnguyen.it@mail.com" Date: Wed, 15 Apr 2026 23:09:38 +0700 Subject: [PATCH 12/12] =?UTF-8?q?fix(sidepanel):=20address=20review=20feed?= =?UTF-8?q?back=20=E2=80=94=20leaks,=20races,=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track object URLs with separate feed/gallery lists and revoke on re-render to stop the blob URL leak when sessions are switched or the gallery redraws. - Add a beforeunload handler that stops the tab capture stream and revokes tracked URLs so closing the sidepanel mid-recording no longer leaks MediaStream tracks. - Cross-reference the duplicated IndexedDB schema between sidepanel.js openMediaDB() and lib/storage.js so future version bumps stay in sync; add a TODO for the sessionId index. - Only fall back to a direct chrome.storage write when the service worker is genuinely unreachable, eliminating the race with flushBuffer when a video save lands after session:stop. - Replace the knownEventCount counter with a knownEventIds Set keyed by timestamp:type so the onChanged listener can dedupe duplicates instead of drifting against the event buffer. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/storage.js | 3 ++ sidepanel/sidepanel.js | 111 ++++++++++++++++++++++++++++++++--------- 2 files changed, 90 insertions(+), 24 deletions(-) diff --git a/lib/storage.js b/lib/storage.js index 97008ae..72967de 100644 --- a/lib/storage.js +++ b/lib/storage.js @@ -1,3 +1,6 @@ +// NOTE: DB_NAME, DB_VERSION, and STORE_SCREENSHOTS are also hard-coded in +// sidepanel/sidepanel.js openMediaDB(). Keep them in sync โ€” bumping the version +// or renaming the store here requires a matching change there. const Storage = { DB_NAME: 'debug-helper', DB_VERSION: 1, diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 7b89ebc..9d97a77 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -7,7 +7,40 @@ let cachedScreenshots = []; // shared screenshot cache for feed thumbnails let currentSessionId = null; // the session being viewed (from history or active) let activeSessionId = null; // the currently recording session (set by service worker) let viewingHistorical = false; // true when viewing a past session from history -let knownEventCount = 0; +let knownEventIds = new Set(); // dedupe set for rendered events (timestamp:type) +// Separate trackers so revoking one group doesn't invalidate