@@ -39,9 +39,13 @@ struct SessionHistoryView: View {
3939 @State private var searchScanTask : Task < Void , Never > ?
4040 @State private var isSearchScanning = false
4141 @State private var didOverscrollTop = false
42+ @State private var pendingMatchScroll = false // scroll to match after turns load from navigation
4243 @FocusState private var isSearchFieldFocused : Bool
4344
44- private static let maxSearchResults = 500
45+ private var currentMatchTurnIndex : Int ? {
46+ guard showSearch, !searchMatchIndices. isEmpty, currentMatchPosition < searchMatchIndices. count else { return nil }
47+ return searchMatchIndices [ currentMatchPosition]
48+ }
4549
4650 var body : some View {
4751 if isLoading {
@@ -99,7 +103,8 @@ struct SessionHistoryView: View {
99103 checkpointMode: checkpointMode,
100104 isHovered: hoveredTurnIndex == turn. index,
101105 isDimmed: checkpointMode && hoveredTurnIndex != nil && turn. index > hoveredTurnIndex!,
102- highlightText: activeQuery. isEmpty ? nil : activeQuery
106+ highlightText: activeQuery. isEmpty ? nil : activeQuery,
107+ isCurrentMatch: currentMatchTurnIndex == turn. index
103108 )
104109 . id ( turn. index)
105110 . overlay {
@@ -126,15 +131,10 @@ struct SessionHistoryView: View {
126131 . onChange ( of: turns. count) {
127132 if activeQuery. isEmpty {
128133 scrollToBottom ( proxy: proxy)
129- } else if !searchMatchIndices. isEmpty,
130- currentMatchPosition < searchMatchIndices. count {
131- // Turns loaded during search — scroll to current match
132- let idx = searchMatchIndices [ currentMatchPosition]
133- if turns. contains ( where: { $0. index == idx } ) {
134- withAnimation ( . easeInOut( duration: 0.2 ) ) {
135- proxy. scrollTo ( idx, anchor: . center)
136- }
137- }
134+ } else if pendingMatchScroll {
135+ // Only scroll when we explicitly loaded turns for search navigation
136+ pendingMatchScroll = false
137+ scrollToCurrentMatch ( proxy: proxy, delay: true )
138138 }
139139 didOverscrollTop = false
140140 }
@@ -144,24 +144,15 @@ struct SessionHistoryView: View {
144144 }
145145 }
146146 . onChange ( of: currentMatchPosition) {
147- guard !isSearchScanning,
148- !searchMatchIndices. isEmpty,
147+ guard !searchMatchIndices. isEmpty,
149148 currentMatchPosition < searchMatchIndices. count else { return }
150149 let idx = searchMatchIndices [ currentMatchPosition]
151- withAnimation ( . easeInOut( duration: 0.2 ) ) {
152- proxy. scrollTo ( idx, anchor: . center)
153- }
154- }
155- . onChange ( of: isSearchScanning) {
156- if !isSearchScanning && !searchMatchIndices. isEmpty {
157- let idx = searchMatchIndices [ currentMatchPosition]
158- if turns. contains ( where: { $0. index == idx } ) {
159- withAnimation ( . easeInOut( duration: 0.2 ) ) {
160- proxy. scrollTo ( idx, anchor: . center)
161- }
162- } else {
163- onLoadAroundTurn ? ( idx)
164- }
150+ if turns. contains ( where: { $0. index == idx } ) {
151+ scrollToCurrentMatch ( proxy: proxy, delay: false )
152+ } else {
153+ // Turn not loaded — load it, pendingMatchScroll triggers scroll after
154+ pendingMatchScroll = true
155+ onLoadAroundTurn ? ( idx)
165156 }
166157 }
167158 }
@@ -200,22 +191,26 @@ struct SessionHistoryView: View {
200191 . foregroundStyle ( . white)
201192 . focused ( $isSearchFieldFocused)
202193 . onKeyPress ( . escape) { dismissSearch ( ) ; return . handled }
203- . onSubmit { navigateSearch ( forward: true ) }
194+ . onSubmit { navigateSearch ( forward: false ) } // Enter goes upward (reverse search)
204195 . onChange ( of: searchText) { scheduleSearch ( ) }
205196
206197 if !activeQuery. isEmpty {
207198 if isSearchScanning {
208199 ProgressView ( )
209200 . controlSize ( . mini)
210201 . tint ( . white. opacity ( 0.5 ) )
211- }
212202
213- if searchMatchIndices. isEmpty && !isSearchScanning {
203+ if !searchMatchIndices. isEmpty {
204+ Text ( " \( searchMatchIndices. count) found… " )
205+ . font ( . caption2)
206+ . foregroundStyle ( . white. opacity ( 0.4 ) )
207+ }
208+ } else if searchMatchIndices. isEmpty {
214209 Text ( " 0 results " )
215210 . font ( . caption2)
216211 . foregroundStyle ( . white. opacity ( 0.4 ) )
217- } else if !searchMatchIndices . isEmpty {
218- Text ( " \( currentMatchPosition + 1 ) / \( searchMatchIndices. count) \( isSearchScanning ? " … " : " " ) " )
212+ } else {
213+ Text ( " \( searchMatchIndices . count - currentMatchPosition ) / \( searchMatchIndices. count) " )
219214 . font ( . caption2)
220215 . foregroundStyle ( . white. opacity ( 0.6 ) )
221216
@@ -284,28 +279,28 @@ struct SessionHistoryView: View {
284279
285280 isSearchScanning = true
286281 let query = activeQuery
287- let maxResults = Self . maxSearchResults
288282
289283 searchScanTask = Task {
290284 var matches : [ Int ] = [ ]
291285
292286 for await matchIndex in TranscriptReader . scanForMatches ( from: path, query: query) {
293287 if Task . isCancelled { break }
294288 matches. append ( matchIndex)
295- if matches. count >= maxResults { break }
296289
297- // Batch update UI every 20 matches or on first match
298- if matches. count == 1 || matches. count % 20 == 0 {
290+ // Update match count display periodically (no position/scroll changes)
291+ if matches. count == 1 || matches. count % 50 == 0 {
299292 searchMatchIndices = matches
300- currentMatchPosition = max ( 0 , matches. count - 1 )
301293 }
302294 }
303295
304296 guard !Task. isCancelled else { return }
305297
298+ // Final update — set position to most recent match, triggering scroll
306299 searchMatchIndices = matches
307- currentMatchPosition = max ( 0 , matches. count - 1 )
308300 isSearchScanning = false
301+ if !matches. isEmpty {
302+ currentMatchPosition = matches. count - 1
303+ }
309304 }
310305 }
311306
@@ -358,6 +353,25 @@ struct SessionHistoryView: View {
358353 . background ( Color . orange. opacity ( 0.15 ) )
359354 }
360355
356+ private func scrollToCurrentMatch( proxy: ScrollViewProxy , delay: Bool ) {
357+ guard !searchMatchIndices. isEmpty,
358+ currentMatchPosition < searchMatchIndices. count else { return }
359+ let idx = searchMatchIndices [ currentMatchPosition]
360+ guard turns. contains ( where: { $0. index == idx } ) else { return }
361+ if delay {
362+ Task { @MainActor in
363+ try ? await Task . sleep ( for: . milliseconds( 80 ) )
364+ withAnimation ( . easeInOut( duration: 0.2 ) ) {
365+ proxy. scrollTo ( idx, anchor: . center)
366+ }
367+ }
368+ } else {
369+ withAnimation ( . easeInOut( duration: 0.2 ) ) {
370+ proxy. scrollTo ( idx, anchor: . center)
371+ }
372+ }
373+ }
374+
361375 private func scrollToBottom( proxy: ScrollViewProxy , force: Bool = false ) {
362376 guard activeQuery. isEmpty else { return }
363377 guard force || isAtBottom else { return }
@@ -380,6 +394,7 @@ struct TurnBlockView: View {
380394 var isHovered : Bool = false
381395 var isDimmed : Bool = false
382396 var highlightText : String ? = nil
397+ var isCurrentMatch : Bool = false
383398
384399 var body : some View {
385400 VStack ( alignment: . leading, spacing: 1 ) {
@@ -396,11 +411,15 @@ struct TurnBlockView: View {
396411 RoundedRectangle ( cornerRadius: 4 )
397412 . fill ( turnBackground)
398413 )
399- . overlay (
400- isSearchMatch
401- ? RoundedRectangle ( cornerRadius: 4 ) . stroke ( Color . yellow. opacity ( 0.3 ) , lineWidth: 1 )
402- : nil
403- )
414+ . overlay {
415+ if isCurrentMatch {
416+ RoundedRectangle ( cornerRadius: 4 ) . stroke ( Color . orange. opacity ( 0.7 ) , lineWidth: 2 )
417+ } else if isSearchMatch {
418+ RoundedRectangle ( cornerRadius: 4 ) . stroke ( Color . yellow. opacity ( 0.3 ) , lineWidth: 1 )
419+ }
420+ }
421+ . scaleEffect ( isCurrentMatch ? 1.02 : 1.0 )
422+ . animation ( . easeInOut( duration: 0.15 ) , value: isCurrentMatch)
404423 . contentShape ( Rectangle ( ) )
405424 }
406425
@@ -414,6 +433,9 @@ struct TurnBlockView: View {
414433 if isHovered && checkpointMode {
415434 return Color . orange. opacity ( 0.1 )
416435 }
436+ if isCurrentMatch {
437+ return Color . orange. opacity ( 0.12 )
438+ }
417439 if isSearchMatch {
418440 return Color . yellow. opacity ( 0.08 )
419441 }
@@ -517,11 +539,13 @@ struct TurnBlockView: View {
517539 result. foregroundColor = color
518540 let lowerText = text. lowercased ( )
519541 var pos = lowerText. startIndex
542+ let hlBg : Color = isCurrentMatch ? . orange. opacity ( 0.5 ) : . yellow. opacity ( 0.35 )
543+ let hlFg : Color = isCurrentMatch ? . orange : . yellow
520544 while let range = lowerText. range ( of: query, range: pos..< lowerText. endIndex) {
521545 if let attrStart = AttributedString . Index ( range. lowerBound, within: result) ,
522546 let attrEnd = AttributedString . Index ( range. upperBound, within: result) {
523- result [ attrStart..< attrEnd] . backgroundColor = . yellow . opacity ( 0.35 )
524- result [ attrStart..< attrEnd] . foregroundColor = . yellow
547+ result [ attrStart..< attrEnd] . backgroundColor = hlBg
548+ result [ attrStart..< attrEnd] . foregroundColor = hlFg
525549 }
526550 pos = range. upperBound
527551 }
0 commit comments