@@ -446,6 +446,51 @@ export function MeetingDetail() {
446446 const [ editingMinutes , setEditingMinutes ] = useState ( false ) ;
447447 const minutesEditRef = useRef < HTMLDivElement > ( null ) ;
448448
449+ // ── Load-more pagination ─────────────────────────────────────────────
450+ // Long sessions (4h+) can produce 500+ transcript parts. Rendering them
451+ // all at once tanks the DOM. We render only the tail by default; the
452+ // user clicks "Load older" to reveal more. The store still holds the
453+ // full array → export / copy / minutes-generation see EVERYTHING. This
454+ // is purely a render-layer optimization.
455+ const TRANSCRIPT_PAGE_SIZE = 200 ;
456+ const [ visibleCount , setVisibleCount ] = useState < number > ( TRANSCRIPT_PAGE_SIZE ) ;
457+ // When switching meetings, reset the window so we don't show 200 parts
458+ // of the previous meeting on the new one.
459+ const prevMeetingKeyRef = useRef < string | number | null > ( null ) ;
460+ useEffect ( ( ) => {
461+ const key = currentMeetingId || draftId || null ;
462+ if ( prevMeetingKeyRef . current !== key ) {
463+ prevMeetingKeyRef . current = key ;
464+ setVisibleCount ( TRANSCRIPT_PAGE_SIZE ) ;
465+ }
466+ } , [ currentMeetingId , draftId ] ) ;
467+
468+ // Visible slice: always include the most recent N parts so live
469+ // transcription stays visible. Absolute index = transcriptParts.length
470+ // - visibleParts.length + visibleIdx. Used for callbacks that mutate
471+ // the full array (edit speaker, delete, edit text).
472+ const hasOlderParts = transcriptParts . length > visibleCount ;
473+ const visibleParts = useMemo (
474+ ( ) => ( hasOlderParts ? transcriptParts . slice ( - visibleCount ) : transcriptParts ) ,
475+ [ transcriptParts , visibleCount , hasOlderParts ] ,
476+ ) ;
477+ const visibleStartIdx = transcriptParts . length - visibleParts . length ;
478+ const handleLoadOlder = ( ) => {
479+ // Preserve scroll position so the user stays anchored at the part
480+ // they were reading instead of jumping when older content prepends.
481+ const el = transcriptRef . current ;
482+ const prevScrollHeight = el ?. scrollHeight ?? 0 ;
483+ const prevScrollTop = el ?. scrollTop ?? 0 ;
484+ setVisibleCount ( ( n ) => n + TRANSCRIPT_PAGE_SIZE ) ;
485+ // After render, restore so visual position relative to previously
486+ // visible items doesn't change.
487+ requestAnimationFrame ( ( ) => {
488+ if ( ! el ) return ;
489+ const delta = el . scrollHeight - prevScrollHeight ;
490+ el . scrollTop = prevScrollTop + delta ;
491+ } ) ;
492+ } ;
493+
449494 const persistTranscriptParts = async ( parts : TranscriptPart [ ] ) => {
450495 const meetingId = currentMeetingId || draftId ;
451496 if ( ! meetingId ) return ;
@@ -688,9 +733,16 @@ export function MeetingDetail() {
688733 } ;
689734 } , [ viewingMeetingId , recording , setTranscriptParts ] ) ;
690735
691- // Auto-scroll
736+ // Auto-scroll: only when the user is near the bottom (so we don't yank
737+ // them away while they're reading older parts after "Load older"). 80px
738+ // tolerance = roughly one transcript row.
692739 useEffect ( ( ) => {
693- transcriptRef . current ?. scrollTo ( { top : transcriptRef . current . scrollHeight , behavior : 'smooth' } ) ;
740+ const el = transcriptRef . current ;
741+ if ( ! el ) return ;
742+ const distanceFromBottom = el . scrollHeight - el . scrollTop - el . clientHeight ;
743+ if ( distanceFromBottom < 80 ) {
744+ el . scrollTo ( { top : el . scrollHeight , behavior : 'smooth' } ) ;
745+ }
694746 } , [ transcriptParts , isTranscribing ] ) ;
695747
696748
@@ -856,15 +908,31 @@ export function MeetingDetail() {
856908 </ div >
857909 ) : (
858910 < div className = { `transcript-list ${ translationEnabled ? 'with-translation' : '' } ` } ref = { transcriptRef } >
859- { transcriptParts . map ( ( part , i ) => {
911+ { hasOlderParts && (
912+ < button
913+ className = "transcript-load-more"
914+ onClick = { handleLoadOlder }
915+ aria-label = { lang === 'vi' ? 'Tải thêm transcript cũ hơn' : 'Load older transcript' }
916+ >
917+ { lang === 'vi'
918+ ? `↑ Tải thêm ${ Math . min ( TRANSCRIPT_PAGE_SIZE , visibleStartIdx ) } đoạn cũ hơn (đang ẩn ${ visibleStartIdx } / tổng ${ transcriptParts . length } )`
919+ : `↑ Load ${ Math . min ( TRANSCRIPT_PAGE_SIZE , visibleStartIdx ) } older parts (${ visibleStartIdx } hidden of ${ transcriptParts . length } total)` }
920+ </ button >
921+ ) }
922+ { visibleParts . map ( ( part , visibleIdx ) => {
923+ // Absolute index in the full transcriptParts array — used
924+ // for callbacks that mutate the full store (edit / delete /
925+ // speaker rename). `visibleIdx` alone would mis-target items
926+ // whenever `hasOlderParts` is true.
927+ const absoluteIdx = visibleStartIdx + visibleIdx ;
860928 const speakerColor = SPEAKER_COLORS [ part . speakerId % SPEAKER_COLORS . length ] ;
861- const isLive = i === transcriptParts . length - 1 && recording ;
929+ const isLive = absoluteIdx === transcriptParts . length - 1 && recording ;
862930 return (
863- < div className = { `transcript-item ${ isLive ? 'live' : '' } ` } key = { i } >
931+ < div className = { `transcript-item ${ isLive ? 'live' : '' } ` } key = { part . chunkId || `t- ${ absoluteIdx } ` } >
864932 < div className = "transcript-actions" >
865933 < button
866934 className = "transcript-action-btn"
867- onClick = { ( ) => startEditSpeaker ( part . speakerId , i ) }
935+ onClick = { ( ) => startEditSpeaker ( part . speakerId , absoluteIdx ) }
868936 title = { lang === 'vi' ? 'Đổi tên speaker (áp dụng toàn bộ)' : 'Rename speaker (apply all)' }
869937 aria-label = { lang === 'vi' ? 'Đổi tên speaker' : 'Rename speaker' }
870938 >
@@ -875,7 +943,7 @@ export function MeetingDetail() {
875943 </ button >
876944 < button
877945 className = "transcript-action-btn t-delete-btn"
878- onClick = { ( ) => deleteTranscriptAt ( i ) }
946+ onClick = { ( ) => deleteTranscriptAt ( absoluteIdx ) }
879947 title = { lang === 'vi' ? 'Xóa đoạn này' : 'Delete this item' }
880948 aria-label = { lang === 'vi' ? 'Xóa đoạn này' : 'Delete this item' }
881949 >
@@ -887,7 +955,7 @@ export function MeetingDetail() {
887955 </ button >
888956 </ div >
889957 < div className = "transcript-time" >
890- { editingSpeakerId === part . speakerId && editingSpeakerAnchorIdx === i ? (
958+ { editingSpeakerId === part . speakerId && editingSpeakerAnchorIdx === absoluteIdx ? (
891959 < div className = "speaker-edit-wrap" >
892960 < input
893961 className = "speaker-edit-input"
@@ -916,7 +984,7 @@ export function MeetingDetail() {
916984 liveTranslationRef = { liveTranslationRef }
917985 onSave = { ( newText ) => {
918986 const next = transcriptParts . map ( ( p , j ) =>
919- j === i ? { ...p , text : newText , translation : '' } : p
987+ j === absoluteIdx ? { ...p , text : newText , translation : '' } : p
920988 ) ;
921989 void applyTranscriptUpdate ( next ) ;
922990 } }
0 commit comments