Skip to content

Commit 21cbc79

Browse files
committed
fix: consistent matching, reverse numbering, and stable scroll for history search
1 parent 4a50f20 commit 21cbc79

File tree

2 files changed

+85
-51
lines changed

2 files changed

+85
-51
lines changed

Sources/KanbanCode/SessionHistoryView.swift

Lines changed: 69 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

Sources/KanbanCodeCore/Adapters/ClaudeCode/TranscriptReader.swift

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,9 @@ public enum TranscriptReader {
149149
}
150150
}
151151

152-
/// Lightweight scan for matching turn indices — no JSON parsing for non-matches.
153-
/// Yields each matching turn index as found. Scans the full file but only does
154-
/// case-insensitive string search on raw line text.
152+
/// Scan for matching turn indices using the same content extraction as the reader.
153+
/// Yields each matching turn index as found. Matches against the same text fields
154+
/// that TurnBlockView displays (textPreview + contentBlocks[].text).
155155
public static func scanForMatches(
156156
from filePath: String,
157157
query: String
@@ -168,19 +168,29 @@ public enum TranscriptReader {
168168
defer { try? handle.close() }
169169

170170
var turnIndex = 0
171+
let queryLower = query.lowercased()
171172

172173
for try await line in handle.bytes.lines {
173174
if Task.isCancelled { break }
174175
guard !line.isEmpty, line.contains("\"type\"") else { continue }
175176

176-
// Minimal JSON parse — just check if it's a user/assistant line
177177
guard let data = line.data(using: .utf8),
178178
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
179179
let type = obj["type"] as? String,
180180
type == "user" || type == "assistant" else { continue }
181181

182-
// Case-insensitive search on the raw line text (covers all content)
183-
if line.range(of: query, options: .caseInsensitive) != nil {
182+
// Extract content the same way the reader/frontend does
183+
let blocks: [ContentBlock]
184+
if type == "user" {
185+
blocks = extractUserBlocks(from: obj)
186+
} else {
187+
blocks = extractAssistantBlocks(from: obj)
188+
}
189+
let textPreview = buildTextPreview(blocks: blocks, role: type)
190+
191+
// Match against the same fields TurnBlockView.isSearchMatch checks
192+
if textPreview.lowercased().contains(queryLower)
193+
|| blocks.contains(where: { $0.text.lowercased().contains(queryLower) }) {
184194
continuation.yield(turnIndex)
185195
}
186196
turnIndex += 1

0 commit comments

Comments
 (0)