Skip to content

Commit a64ff2e

Browse files
committed
Fix SessionStart triggering in-progress and add streaming history search
SessionStart was treated the same as UserPromptSubmit, causing cards to stay in-progress for 5 minutes after resuming even when Claude is idle at the prompt. Now SessionStart returns .idleWaiting instead. Also adds non-blocking history search: scans JSONL file directly via TranscriptReader.scanForMatches() stream, loads turns on-demand around matches with loadAroundTurn(), and shows scanning progress in search bar.
1 parent ce9305f commit a64ff2e

File tree

4 files changed

+208
-43
lines changed

4 files changed

+208
-43
lines changed

Sources/KanbanCode/CardDetailView.swift

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,8 @@ struct CardDetailView: View {
337337
showCheckpointConfirm = true
338338
},
339339
onLoadMore: { Task { await loadMoreHistory() } },
340-
onLoadAll: { Task { await loadAllHistory() } }
340+
onLoadAroundTurn: { turnIndex in Task { await loadAroundTurn(turnIndex) } },
341+
sessionPath: card.link.sessionLink?.sessionPath ?? card.session?.jsonlPath
341342
)
342343
case .issue:
343344
issueTabView
@@ -1183,18 +1184,24 @@ struct CardDetailView: View {
11831184
isLoadingMore = false
11841185
}
11851186

1186-
/// Load the entire conversation history (for search).
1187-
private func loadAllHistory() async {
1188-
guard hasMoreTurns, !isLoadingMore else { return }
1187+
/// Load turns around a specific turn index (for search match navigation).
1188+
/// Loads a page-sized chunk around the target, merging with existing turns.
1189+
private func loadAroundTurn(_ targetIndex: Int) async {
11891190
guard let path = card.link.sessionLink?.sessionPath ?? card.session?.jsonlPath else { return }
11901191
isLoadingMore = true
1192+
1193+
let halfPage = Self.pageSize / 2
1194+
let rangeStart = max(0, targetIndex - halfPage)
1195+
let rangeEnd = targetIndex + halfPage
1196+
11911197
do {
1192-
let all = try await TranscriptReader.readTurns(from: path)
1193-
turns = all
1194-
hasMoreTurns = false
1195-
} catch {
1196-
// Silently fail
1197-
}
1198+
let chunk = try await TranscriptReader.readRange(from: path, turnRange: rangeStart..<rangeEnd)
1199+
var byIndex: [Int: ConversationTurn] = [:]
1200+
for t in turns { byIndex[t.index] = t }
1201+
for t in chunk { byIndex[t.index] = t }
1202+
turns = byIndex.values.sorted { $0.index < $1.index }
1203+
hasMoreTurns = (turns.first?.index ?? 0) > 0
1204+
} catch { }
11981205
isLoadingMore = false
11991206
}
12001207

Sources/KanbanCode/SessionHistoryView.swift

Lines changed: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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 = ""

Sources/KanbanCodeCore/Adapters/ClaudeCode/ClaudeCodeActivityDetector.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public actor ClaudeCodeActivityDetector: ActivityDetector {
8989
}
9090

9191
switch lastEvent.eventName {
92-
case "UserPromptSubmit", "SessionStart":
92+
case "UserPromptSubmit":
9393
// After a prompt, Claude is actively working. Stay in this state until:
9494
// 1. A Stop hook fires (handled by the "Stop" case below)
9595
// 2. File stale >3s AND last jsonl line is "[Request interrupted by user]"
@@ -121,6 +121,11 @@ public actor ClaudeCodeActivityDetector: ActivityDetector {
121121

122122
return .activelyWorking
123123

124+
case "SessionStart":
125+
// Session opened or resumed — Claude is at the prompt waiting for input.
126+
// NOT actively working yet (that requires UserPromptSubmit).
127+
return .idleWaiting
128+
124129
case "Stop":
125130
// Stop is the definitive signal — immediately needs attention
126131
return .needsAttention

Sources/KanbanCodeCore/Adapters/ClaudeCode/TranscriptReader.swift

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,108 @@ public enum TranscriptReader {
9090
)
9191
}
9292

93+
/// Stream all conversation turns from a .jsonl file, yielding each turn as it's parsed.
94+
/// Callers receive turns incrementally without waiting for the full file to load.
95+
public static func streamAllTurns(from filePath: String) -> AsyncStream<ConversationTurn> {
96+
AsyncStream { continuation in
97+
let task = Task.detached {
98+
guard FileManager.default.fileExists(atPath: filePath) else {
99+
continuation.finish()
100+
return
101+
}
102+
do {
103+
let url = URL(fileURLWithPath: filePath)
104+
let handle = try FileHandle(forReadingFrom: url)
105+
defer { try? handle.close() }
106+
107+
var lineNumber = 0
108+
var turnIndex = 0
109+
110+
for try await line in handle.bytes.lines {
111+
if Task.isCancelled { break }
112+
lineNumber += 1
113+
guard !line.isEmpty, line.contains("\"type\"") else { continue }
114+
115+
guard let data = line.data(using: .utf8),
116+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
117+
let type = obj["type"] as? String,
118+
type == "user" || type == "assistant" else { continue }
119+
120+
let blocks: [ContentBlock]
121+
let textPreview: String
122+
123+
if type == "user" {
124+
blocks = extractUserBlocks(from: obj)
125+
textPreview = buildTextPreview(blocks: blocks, role: type)
126+
} else {
127+
blocks = extractAssistantBlocks(from: obj)
128+
textPreview = buildTextPreview(blocks: blocks, role: type)
129+
}
130+
131+
let timestamp = obj["timestamp"] as? String
132+
133+
continuation.yield(ConversationTurn(
134+
index: turnIndex,
135+
lineNumber: lineNumber,
136+
role: type,
137+
textPreview: textPreview,
138+
timestamp: timestamp,
139+
contentBlocks: blocks
140+
))
141+
turnIndex += 1
142+
}
143+
} catch {
144+
// File read error — just finish the stream
145+
}
146+
continuation.finish()
147+
}
148+
continuation.onTermination = { _ in task.cancel() }
149+
}
150+
}
151+
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.
155+
public static func scanForMatches(
156+
from filePath: String,
157+
query: String
158+
) -> AsyncStream<Int> {
159+
AsyncStream { continuation in
160+
let task = Task.detached {
161+
guard FileManager.default.fileExists(atPath: filePath) else {
162+
continuation.finish()
163+
return
164+
}
165+
do {
166+
let url = URL(fileURLWithPath: filePath)
167+
let handle = try FileHandle(forReadingFrom: url)
168+
defer { try? handle.close() }
169+
170+
var turnIndex = 0
171+
172+
for try await line in handle.bytes.lines {
173+
if Task.isCancelled { break }
174+
guard !line.isEmpty, line.contains("\"type\"") else { continue }
175+
176+
// Minimal JSON parse — just check if it's a user/assistant line
177+
guard let data = line.data(using: .utf8),
178+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
179+
let type = obj["type"] as? String,
180+
type == "user" || type == "assistant" else { continue }
181+
182+
// Case-insensitive search on the raw line text (covers all content)
183+
if line.range(of: query, options: .caseInsensitive) != nil {
184+
continuation.yield(turnIndex)
185+
}
186+
turnIndex += 1
187+
}
188+
} catch { }
189+
continuation.finish()
190+
}
191+
continuation.onTermination = { _ in task.cancel() }
192+
}
193+
}
194+
93195
/// Load earlier turns before the current set (for "load more" pagination).
94196
public static func readRange(from filePath: String, turnRange: Range<Int>) async throws -> [ConversationTurn] {
95197
guard FileManager.default.fileExists(atPath: filePath) else { return [] }

0 commit comments

Comments
 (0)