personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

transcripts(workspace): abort stale segment loads, preserve tab in hash, show nav hint

- AbortController is threaded through loadSegmentContent to fetch, prepareScreenFrames,
prefetchThumbnails, and prefetchGroupThumbnails so rapid segment clicks cancel in-flight
work cleanly.
- URL hashes now use #<segment>/<tab> so reloads and shared links preserve tab state;
missing or unknown tabs fall through silently to transcript.
- The [ ] nav hint now becomes visible as soon as buildZoomSegments renders one or more
pills, instead of waiting for the first segment click.
- Added a :focus-visible rule for .tr-tab-pane so the keyboard-focusable pane has a
visible focus ring.

+87 -25
+87 -25
apps/transcripts/workspace.html
··· 378 378 height: 100%; 379 379 } 380 380 381 + .tr-tab-pane:focus-visible { 382 + outline: 2px solid var(--accent, #4a9eff); 383 + outline-offset: 2px; 384 + } 385 + 381 386 .tr-tab-pane.active { 382 387 display: block; 383 388 } ··· 1521 1526 }); 1522 1527 } 1523 1528 1524 - async prefetchThumbnails(videoUrl, frameIds, onProgress = null) { 1529 + async prefetchThumbnails(videoUrl, frameIds, onProgress = null, signal = null) { 1525 1530 if (!frameIds || frameIds.length === 0) return null; 1526 1531 1527 1532 const sorted = [...new Set(frameIds)].sort((a, b) => a - b); 1528 1533 for (let i = 0; i < sorted.length; i += 1) { 1534 + if (signal?.aborted) return this.videos.get(videoUrl); 1529 1535 const frameId = sorted[i]; 1530 1536 await this.captureThumbnail(videoUrl, frameId, 120, 68); 1531 1537 if (onProgress) onProgress(i + 1, sorted.length); ··· 1972 1978 zoomSegments.innerHTML = ''; 1973 1979 1974 1980 if (filtered.length === 0) { 1981 + document.getElementById('trNavHint').classList.remove('visible'); 1975 1982 const empty = document.createElement('div'); 1976 1983 empty.className = 'tr-zoom-empty'; 1977 1984 empty.textContent = 'No segments in selected range'; ··· 1979 1986 return; 1980 1987 } 1981 1988 1989 + document.getElementById('trNavHint').classList.add('visible'); 1982 1990 filtered.forEach(seg => { 1983 1991 const segStart = parseTime(seg.start); 1984 1992 const segEnd = parseTime(seg.end); ··· 2036 2044 }); 2037 2045 } 2038 2046 2039 - function selectSegment(seg, updateHash = true) { 2047 + function selectSegment(seg, updateHash = true, requestedTabId = null) { 2040 2048 selectedSegment = seg; 2041 - document.getElementById('trNavHint').classList.add('visible'); 2042 2049 2043 2050 // Update URL hash for shareable links 2044 2051 if (updateHash) { 2045 - history.replaceState(null, '', `#${seg.key}`); 2052 + const tab = activeTab || 'transcript'; 2053 + history.replaceState(null, '', `#${seg.key}/${tab}`); 2046 2054 } 2047 2055 2048 2056 // Update active state in zoom view ··· 2057 2065 deleteBtn.classList.add('visible'); 2058 2066 2059 2067 // Load transcript content 2060 - loadSegmentContent(seg); 2068 + loadSegmentContent(seg, requestedTabId); 2061 2069 } 2062 2070 2063 2071 // Step through segments with [ ] keys ··· 2090 2098 let currentVideoFiles = {}; // filename -> video URL mapping 2091 2099 let groupEntriesByIdx = new Map(); 2092 2100 let activeTab = null; 2101 + let currentSegmentAbort = null; 2093 2102 let tabPanes = {}; // tabId -> pane element 2094 2103 let screenDecoded = false; 2095 2104 2096 - function loadSegmentContent(seg) { 2105 + function loadSegmentContent(seg, requestedTabId = null) { 2106 + currentSegmentAbort?.abort(); 2107 + currentSegmentAbort = new AbortController(); 2108 + const signal = currentSegmentAbort.signal; 2097 2109 const segmentToken = seg.key; 2098 2110 2099 2111 // Clear old data, videos, tabs, and show loading message immediately ··· 2109 2121 panel.innerHTML = '<div class="tr-unified-empty"><p>Loading segment...</p></div>'; 2110 2122 panel.classList.add('loading'); 2111 2123 2112 - fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`) 2124 + fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`, { signal }) 2113 2125 .then(r => r.json()) 2114 2126 .then(data => { 2115 2127 if (!selectedSegment || selectedSegment.key !== segmentToken) { ··· 2120 2132 updateRangeText(); 2121 2133 buildTabBar(data); 2122 2134 activateTab('transcript'); 2135 + if ( 2136 + requestedTabId 2137 + && [...tabsContainer.querySelectorAll('.tr-tab')].some( 2138 + tab => tab.dataset.tab === requestedTabId 2139 + ) 2140 + ) { 2141 + activateTab(requestedTabId); 2142 + } 2123 2143 const warningNotice = document.getElementById('trWarningNotice'); 2124 2144 if (data.warnings > 0) { 2125 2145 document.getElementById('trWarningText').textContent = data.warnings + ' warning' + (data.warnings === 1 ? '' : 's') + ' during processing'; ··· 2128 2148 warningNotice.classList.remove('visible'); 2129 2149 } 2130 2150 }) 2131 - .catch(() => { 2151 + .catch(err => { 2152 + if (err.name === 'AbortError') { 2153 + return; 2154 + } 2132 2155 if (!selectedSegment || selectedSegment.key !== segmentToken) { 2133 2156 return; 2134 2157 } ··· 2139 2162 }); 2140 2163 } 2141 2164 2142 - function prepareScreenFrames(data, targetEl, segmentToken) { 2165 + function prepareScreenFrames(data, targetEl, segmentToken, signal) { 2143 2166 if (screenDecoded) { 2144 2167 return Promise.resolve(); 2145 2168 } ··· 2202 2225 Object.entries(currentVideoFiles).forEach(([filename, url]) => { 2203 2226 const frameIds = Array.from(nonBasicByVideo.get(filename) || []); 2204 2227 if (frameIds.length > 0) { 2205 - decodeJobs.push(frameCapture.prefetchThumbnails(url, frameIds, makeProgressHandler(url))); 2228 + decodeJobs.push( 2229 + frameCapture.prefetchThumbnails( 2230 + url, 2231 + frameIds, 2232 + makeProgressHandler(url), 2233 + signal 2234 + ) 2235 + ); 2206 2236 } 2207 2237 }); 2208 2238 ··· 2295 2325 } else if (tabId === 'screen') { 2296 2326 const segmentToken = selectedSegment?.key; 2297 2327 pane.innerHTML = '<div class="tr-unified-empty"><p data-role="loading-status">Loading screen entries...</p></div>'; 2298 - prepareScreenFrames(segmentData, pane, segmentToken) 2328 + prepareScreenFrames( 2329 + segmentData, 2330 + pane, 2331 + segmentToken, 2332 + currentSegmentAbort?.signal 2333 + ) 2299 2334 .then(() => { 2300 2335 if (!selectedSegment || selectedSegment.key !== segmentToken) { 2301 2336 return; ··· 2323 2358 2324 2359 pane.classList.add('active'); 2325 2360 activeTab = tabId; 2361 + if (selectedSegment) { 2362 + history.replaceState(null, '', `#${selectedSegment.key}/${tabId}`); 2363 + } 2326 2364 } 2327 2365 2328 2366 function renderSegmentTimeline(data, showAudio, showScreen, targetEl) { ··· 2491 2529 const groupIdx = parseInt(groupEl.dataset.idx, 10); 2492 2530 if (isNaN(groupIdx)) return; 2493 2531 const entries = groupEntriesByIdx.get(groupIdx) || []; 2494 - prefetchGroupThumbnails(entries, groupEl, targetEl); 2532 + prefetchGroupThumbnails( 2533 + entries, 2534 + groupEl, 2535 + targetEl, 2536 + currentSegmentAbort?.signal 2537 + ); 2495 2538 }); 2496 2539 header.addEventListener('keydown', e => { 2497 2540 if (e.key === 'Enter' || e.key === ' ') { ··· 2521 2564 } 2522 2565 } 2523 2566 2524 - function prefetchGroupThumbnails(entries, groupEl, targetEl) { 2567 + function prefetchGroupThumbnails(entries, groupEl, targetEl, signal) { 2525 2568 const frameIdsByVideo = new Map(); 2526 2569 for (const entry of entries) { 2527 2570 const filename = entry.source_ref?.filename; ··· 2537 2580 for (const [filename, frameIds] of frameIdsByVideo.entries()) { 2538 2581 const url = currentVideoFiles[filename]; 2539 2582 if (!url) continue; 2540 - jobs.push(frameCapture.prefetchThumbnails(url, Array.from(frameIds))); 2583 + jobs.push( 2584 + frameCapture.prefetchThumbnails(url, Array.from(frameIds), null, signal) 2585 + ); 2541 2586 } 2542 2587 2543 2588 if (jobs.length > 0) { ··· 2969 3014 } 2970 3015 2971 3016 // Check for hash fragment to auto-select segment 2972 - const hash = window.location.hash.slice(1); 2973 - if (hash) { 2974 - const seg = allSegments.find(s => s.key === hash); 3017 + const rawHash = window.location.hash.slice(1); 3018 + const [segKey, tabIdFromHash] = rawHash.split('/', 2); 3019 + if (segKey) { 3020 + const seg = allSegments.find(s => s.key === segKey); 2975 3021 if (seg) { 2976 3022 const segStart = parseTime(seg.start); 2977 3023 const segEnd = parseTime(seg.end); ··· 2982 3028 range = { start: newStart, end: newStart + rangeLen }; 2983 3029 renderTimeline(); 2984 3030 updateZoom(); 2985 - selectSegment(seg, false); 3031 + selectSegment(seg, false, tabIdFromHash); 2986 3032 } 2987 3033 } 2988 3034 }) ··· 2994 3040 2995 3041 // Handle browser back/forward 2996 3042 window.addEventListener('hashchange', () => { 2997 - const hash = window.location.hash.slice(1); 2998 - if (hash) { 2999 - const seg = allSegments.find(s => s.key === hash); 3000 - if (seg && (!selectedSegment || selectedSegment.key !== hash)) { 3001 - selectSegment(seg, false); 3002 - } 3043 + const rawHash = window.location.hash.slice(1); 3044 + const [segKey, tabIdFromHash] = rawHash.split('/', 2); 3045 + if (!segKey) { 3046 + return; 3047 + } 3048 + 3049 + const seg = allSegments.find(s => s.key === segKey); 3050 + if (!seg) { 3051 + return; 3052 + } 3053 + 3054 + if (!selectedSegment || selectedSegment.key !== segKey) { 3055 + selectSegment(seg, false, tabIdFromHash); 3056 + return; 3057 + } 3058 + 3059 + if ( 3060 + tabIdFromHash 3061 + && [...tabsContainer.querySelectorAll('.tr-tab')].some( 3062 + tab => tab.dataset.tab === tabIdFromHash 3063 + ) 3064 + ) { 3065 + activateTab(tabIdFromHash); 3003 3066 } 3004 3067 }); 3005 3068 ··· 3100 3163 3101 3164 // Hide delete button 3102 3165 deleteBtn.classList.remove('visible'); 3103 - document.getElementById('trNavHint').classList.remove('visible'); 3104 3166 3105 3167 // Clear URL hash 3106 3168 history.replaceState(null, '', window.location.pathname);