@@ -25,20 +25,23 @@ struct SessionHistoryView: View {
2525 var onCancelCheckpoint : ( ( ) -> Void ) ?
2626 var onSelectTurn : ( ( ConversationTurn ) -> Void ) ?
2727 var onLoadMore : ( ( ) -> Void ) ?
28- var onLoadAll : ( ( ) -> Void ) ?
28+ var onLoadAroundTurn : ( ( Int ) -> Void ) ?
29+ var sessionPath : String ?
2930
3031 @State private var hoveredTurnIndex : Int ?
3132 @State private var isAtBottom = true
3233 @State private var showSearch = false
3334 @State private var searchText = " "
3435 @State private var activeQuery = " " // debounced, min 2 chars
35- @State private var searchMatchIndices : [ Int ] = [ ]
36- @State private var currentMatchPosition : Int = 0
36+ @State private var searchMatchIndices : [ Int ] = [ ] // all found match turn indices (ascending)
37+ @State private var currentMatchPosition : Int = 0 // index into searchMatchIndices, 0 = most recent (last)
3738 @State private var searchDebounceTask : Task < Void , Never > ?
39+ @State private var searchScanTask : Task < Void , Never > ?
40+ @State private var isSearchScanning = false
3841 @State private var didOverscrollTop = false
3942 @FocusState private var isSearchFieldFocused : Bool
4043
41- private static let maxSearchResults = 200
44+ private static let maxSearchResults = 500
4245
4346 var body : some View {
4447 if isLoading {
@@ -121,7 +124,18 @@ struct SessionHistoryView: View {
121124 }
122125 . onAppear { scrollToBottom ( proxy: proxy, force: true ) }
123126 . onChange ( of: turns. count) {
124- scrollToBottom ( proxy: proxy)
127+ if activeQuery. isEmpty {
128+ 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+ }
138+ }
125139 didOverscrollTop = false
126140 }
127141 . onChange ( of: didOverscrollTop) {
@@ -130,13 +144,26 @@ struct SessionHistoryView: View {
130144 }
131145 }
132146 . onChange ( of: currentMatchPosition) {
133- guard !searchMatchIndices. isEmpty,
147+ guard !isSearchScanning,
148+ !searchMatchIndices. isEmpty,
134149 currentMatchPosition < searchMatchIndices. count else { return }
135150 let idx = searchMatchIndices [ currentMatchPosition]
136151 withAnimation ( . easeInOut( duration: 0.2 ) ) {
137152 proxy. scrollTo ( idx, anchor: . center)
138153 }
139154 }
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+ }
165+ }
166+ }
140167 }
141168
142169 // Search overlay
@@ -149,8 +176,6 @@ struct SessionHistoryView: View {
149176 Button ( " " ) {
150177 showSearch = true
151178 isSearchFieldFocused = true
152- // Load full history for search if not all loaded yet
153- if hasMoreTurns { onLoadAll ? ( ) }
154179 }
155180 . keyboardShortcut ( " f " , modifiers: . command)
156181 . hidden ( )
@@ -178,17 +203,19 @@ struct SessionHistoryView: View {
178203 . onSubmit { navigateSearch ( forward: true ) }
179204 . onChange ( of: searchText) { scheduleSearch ( ) }
180205
181- if isLoadingMore {
182- ProgressView ( )
183- . controlSize ( . mini)
184- . tint ( . white. opacity ( 0.5 ) )
185- } else if !activeQuery. isEmpty {
186- if searchMatchIndices. isEmpty {
206+ if !activeQuery. isEmpty {
207+ if isSearchScanning {
208+ ProgressView ( )
209+ . controlSize ( . mini)
210+ . tint ( . white. opacity ( 0.5 ) )
211+ }
212+
213+ if searchMatchIndices. isEmpty && !isSearchScanning {
187214 Text ( " 0 results " )
188215 . font ( . caption2)
189216 . foregroundStyle ( . white. opacity ( 0.4 ) )
190- } else {
191- Text ( " \( currentMatchPosition + 1 ) / \( searchMatchIndices. count) \( searchMatchIndices . count >= Self . maxSearchResults ? " + " : " " ) " )
217+ } else if !searchMatchIndices . isEmpty {
218+ Text ( " \( currentMatchPosition + 1 ) / \( searchMatchIndices. count) \( isSearchScanning ? " … " : " " ) " )
192219 . font ( . caption2)
193220 . foregroundStyle ( . white. opacity ( 0.6 ) )
194221
@@ -232,6 +259,8 @@ struct SessionHistoryView: View {
232259 activeQuery = " "
233260 searchMatchIndices = [ ]
234261 currentMatchPosition = 0
262+ searchScanTask? . cancel ( )
263+ isSearchScanning = false
235264 return
236265 }
237266
@@ -242,27 +271,42 @@ struct SessionHistoryView: View {
242271 try ? await Task . sleep ( for: . milliseconds( 250 ) )
243272 guard !Task. isCancelled else { return }
244273 activeQuery = searchText
245- updateSearchMatches ( )
274+ startScan ( )
246275 }
247276 }
248277
249- private func updateSearchMatches( ) {
250- guard !activeQuery. isEmpty else {
251- searchMatchIndices = [ ]
252- currentMatchPosition = 0
253- return
254- }
255- let query = activeQuery. lowercased ( )
256- var indices : [ Int ] = [ ]
257- for turn in turns {
258- if turn. textPreview. lowercased ( ) . contains ( query)
259- || turn. contentBlocks. contains ( where: { $0. text. lowercased ( ) . contains ( query) } ) {
260- indices. append ( turn. index)
261- if indices. count >= Self . maxSearchResults { break }
278+ private func startScan( ) {
279+ searchScanTask? . cancel ( )
280+ searchMatchIndices = [ ]
281+ currentMatchPosition = 0
282+
283+ guard let path = sessionPath, !activeQuery. isEmpty else { return }
284+
285+ isSearchScanning = true
286+ let query = activeQuery
287+ let maxResults = Self . maxSearchResults
288+
289+ searchScanTask = Task {
290+ var matches : [ Int ] = [ ]
291+
292+ for await matchIndex in TranscriptReader . scanForMatches ( from: path, query: query) {
293+ if Task . isCancelled { break }
294+ matches. append ( matchIndex)
295+ if matches. count >= maxResults { break }
296+
297+ // Batch update UI every 20 matches or on first match
298+ if matches. count == 1 || matches. count % 20 == 0 {
299+ searchMatchIndices = matches
300+ currentMatchPosition = max ( 0 , matches. count - 1 )
301+ }
262302 }
303+
304+ guard !Task. isCancelled else { return }
305+
306+ searchMatchIndices = matches
307+ currentMatchPosition = max ( 0 , matches. count - 1 )
308+ isSearchScanning = false
263309 }
264- searchMatchIndices = indices
265- currentMatchPosition = max ( 0 , indices. count - 1 )
266310 }
267311
268312 private func navigateSearch( forward: Bool ) {
@@ -272,10 +316,17 @@ struct SessionHistoryView: View {
272316 } else {
273317 currentMatchPosition = ( currentMatchPosition - 1 + searchMatchIndices. count) % searchMatchIndices. count
274318 }
319+ // Ensure the target match turn is loaded
320+ let targetIndex = searchMatchIndices [ currentMatchPosition]
321+ if !turns. contains ( where: { $0. index == targetIndex } ) {
322+ onLoadAroundTurn ? ( targetIndex)
323+ }
275324 }
276325
277326 private func dismissSearch( ) {
278327 searchDebounceTask? . cancel ( )
328+ searchScanTask? . cancel ( )
329+ isSearchScanning = false
279330 showSearch = false
280331 isSearchFieldFocused = false
281332 searchText = " "
0 commit comments