Skip to content

Commit 7292231

Browse files
committed
fix: activity detector redesign, fork worktree fix, search/terminal improvements
Activity detector: - Polling never returns .activelyWorking — only hooks promote to In Progress - Fast Ctrl+C detection: reads last jsonl line for "Request interrupted by user" - 5-minute file timeout as safety net for killed processes/abandoned sessions - sleep 60s scenarios stay activelyWorking (< 5min timeout) - Configurable activeTimeout (default 300s) Fork worktree re-attachment fix: - Set manualOverrides.worktreePath when forking with "project root" - Reconciler respects override: won't re-attach worktree to forked cards - Don't offer worktree cleanup when another active card depends on it Search & history: - Dark color scheme for search bar placeholder visibility - Search highlights on tool_use and tool_result lines - Escape dismisses search before drawer (onKeyPress responder level) - Scroll position preserved on search dismiss - Full history search loads all turns Terminal: - Tmux copy-mode exit cooldown (300ms) prevents momentum re-entry - Bottom scroll detection via #{scroll_position}, explicit q exit - Key press after scroll: consume + sequential tmux send-keys - Hide SwiftTerm's private NSScroller scrollbar Session store: - Streaming search with BM25 scoring and progressive results - Fork preserves original file mtime (prevents false inProgress) - Lowercase UUID for Claude Code compatibility Other: - Discover branches from checkout -b, switch -c, worktree add -b - Move to Project: configured projects only, confirmation dialog - Move to Project available in drawer actions menu Tests: 340 passing (22 activity detector, 33 reconciler)
1 parent baa67b6 commit 7292231

17 files changed

+1004
-275
lines changed

Sources/KanbanCode/BoardView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ struct BoardView: View {
99
var onForkCard: (String) -> Void = { _ in }
1010
var onCopyResumeCmd: (String) -> Void = { _ in }
1111
var onCleanupWorktree: (String) -> Void = { _ in }
12+
var canCleanupWorktree: (String) -> Bool = { _ in true }
1213
var onArchiveCard: (String) -> Void = { _ in }
1314
var onDeleteCard: (String) -> Void = { _ in }
1415
var availableProjects: [(name: String, path: String)] = []
@@ -54,6 +55,7 @@ struct BoardView: View {
5455
onForkCard: onForkCard,
5556
onCopyResumeCmd: onCopyResumeCmd,
5657
onCleanupWorktree: onCleanupWorktree,
58+
canCleanupWorktree: canCleanupWorktree,
5759
onDeleteCard: onDeleteCard,
5860
availableProjects: availableProjects,
5961
onMoveToProject: onMoveToProject,

Sources/KanbanCode/CardDetailView.swift

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,14 @@ struct CardDetailView: View {
5151
var onAddBranch: (String) -> Void = { _ in }
5252
var onAddIssue: (Int) -> Void = { _ in }
5353
var onCleanupWorktree: () -> Void = {}
54+
var canCleanupWorktree: Bool = true
5455
var onDeleteCard: () -> Void = {}
5556
var onCreateTerminal: () -> Void = {}
5657
var onKillTerminal: (String) -> Void = { _ in }
5758
var onCancelLaunch: () -> Void = {}
5859
var onDiscover: () -> Void = {}
60+
var availableProjects: [(name: String, path: String)] = []
61+
var onMoveToProject: (String) -> Void = { _ in }
5962
@Binding var focusTerminal: Bool
6063

6164
@AppStorage("preferredEditorBundleId") private var editorBundleId: String = "dev.zed.Zed"
@@ -108,7 +111,7 @@ struct CardDetailView: View {
108111

109112
let sessionStore: SessionStore
110113

111-
init(card: KanbanCodeCard, sessionStore: SessionStore = ClaudeCodeSessionStore(), onResume: @escaping () -> Void = {}, onRename: @escaping (String) -> Void = { _ in }, onFork: @escaping (_ keepWorktree: Bool) -> Void = { _ in }, onDismiss: @escaping () -> Void = {}, onUnlink: @escaping (Action.LinkType) -> Void = { _ in }, onAddBranch: @escaping (String) -> Void = { _ in }, onAddIssue: @escaping (Int) -> Void = { _ in }, onCleanupWorktree: @escaping () -> Void = {}, onDeleteCard: @escaping () -> Void = {}, onCreateTerminal: @escaping () -> Void = {}, onKillTerminal: @escaping (String) -> Void = { _ in }, onCancelLaunch: @escaping () -> Void = {}, onDiscover: @escaping () -> Void = {}, focusTerminal: Binding<Bool> = .constant(false)) {
114+
init(card: KanbanCodeCard, sessionStore: SessionStore = ClaudeCodeSessionStore(), onResume: @escaping () -> Void = {}, onRename: @escaping (String) -> Void = { _ in }, onFork: @escaping (_ keepWorktree: Bool) -> Void = { _ in }, onDismiss: @escaping () -> Void = {}, onUnlink: @escaping (Action.LinkType) -> Void = { _ in }, onAddBranch: @escaping (String) -> Void = { _ in }, onAddIssue: @escaping (Int) -> Void = { _ in }, onCleanupWorktree: @escaping () -> Void = {}, canCleanupWorktree: Bool = true, onDeleteCard: @escaping () -> Void = {}, onCreateTerminal: @escaping () -> Void = {}, onKillTerminal: @escaping (String) -> Void = { _ in }, onCancelLaunch: @escaping () -> Void = {}, onDiscover: @escaping () -> Void = {}, availableProjects: [(name: String, path: String)] = [], onMoveToProject: @escaping (String) -> Void = { _ in }, focusTerminal: Binding<Bool> = .constant(false)) {
112115
self.card = card
113116
self.sessionStore = sessionStore
114117
self.onResume = onResume
@@ -119,11 +122,14 @@ struct CardDetailView: View {
119122
self.onAddBranch = onAddBranch
120123
self.onAddIssue = onAddIssue
121124
self.onCleanupWorktree = onCleanupWorktree
125+
self.canCleanupWorktree = canCleanupWorktree
122126
self.onDeleteCard = onDeleteCard
123127
self.onCreateTerminal = onCreateTerminal
124128
self.onKillTerminal = onKillTerminal
125129
self.onCancelLaunch = onCancelLaunch
126130
self.onDiscover = onDiscover
131+
self.availableProjects = availableProjects
132+
self.onMoveToProject = onMoveToProject
127133
self._focusTerminal = focusTerminal
128134
_selectedTab = State(initialValue: Self.initialTab(for: card))
129135
}
@@ -295,7 +301,7 @@ struct CardDetailView: View {
295301
.pickerStyle(.segmented)
296302
.labelsHidden()
297303

298-
if card.link.worktreeLink != nil {
304+
if card.link.worktreeLink != nil, canCleanupWorktree {
299305
Spacer()
300306
Button(role: .destructive, action: onCleanupWorktree) {
301307
Label("Cleanup Worktree", systemImage: "trash")
@@ -322,7 +328,8 @@ struct CardDetailView: View {
322328
checkpointTurn = turn
323329
showCheckpointConfirm = true
324330
},
325-
onLoadMore: { Task { await loadMoreHistory() } }
331+
onLoadMore: { Task { await loadMoreHistory() } },
332+
onLoadAll: { Task { await loadAllHistory() } }
326333
)
327334
case .issue:
328335
issueTabView
@@ -1082,7 +1089,7 @@ struct CardDetailView: View {
10821089

10831090
if card.link.sessionLink != nil || card.link.worktreeLink != nil {
10841091
menu.addItem(NSMenuItem.separator())
1085-
menu.addActionItem("Discover PRs", image: "arrow.triangle.pull") { [self] in onDiscover() }
1092+
menu.addActionItem("Discover Branch", image: "arrow.triangle.pull") { [self] in onDiscover() }
10861093
}
10871094

10881095
if let issue = card.link.issueLink {
@@ -1091,11 +1098,26 @@ struct CardDetailView: View {
10911098
}
10921099
}
10931100

1094-
if card.link.worktreeLink != nil {
1101+
if card.link.worktreeLink != nil, canCleanupWorktree {
10951102
menu.addItem(NSMenuItem.separator())
10961103
menu.addActionItem("Cleanup Worktree", image: "trash") { [self] in onCleanupWorktree() }
10971104
}
10981105

1106+
let currentPath = card.link.projectPath
1107+
let otherProjects = availableProjects.filter { $0.path != currentPath }
1108+
if !otherProjects.isEmpty {
1109+
menu.addItem(NSMenuItem.separator())
1110+
let moveItem = NSMenuItem(title: "Move to Project", action: nil, keyEquivalent: "")
1111+
moveItem.image = NSImage(systemSymbolName: "folder.badge.arrow.forward", accessibilityDescription: nil)
1112+
let submenu = NSMenu()
1113+
for project in otherProjects {
1114+
let item = submenu.addActionItem(project.name) { [self] in onMoveToProject(project.path) }
1115+
_ = item
1116+
}
1117+
moveItem.submenu = submenu
1118+
menu.addItem(moveItem)
1119+
}
1120+
10991121
menu.addItem(NSMenuItem.separator())
11001122
menu.addActionItem("Delete Card", image: "trash") { [self] in onDeleteCard(); onDismiss() }
11011123

@@ -1140,6 +1162,21 @@ struct CardDetailView: View {
11401162
isLoadingMore = false
11411163
}
11421164

1165+
/// Load the entire conversation history (for search).
1166+
private func loadAllHistory() async {
1167+
guard hasMoreTurns, !isLoadingMore else { return }
1168+
guard let path = card.link.sessionLink?.sessionPath ?? card.session?.jsonlPath else { return }
1169+
isLoadingMore = true
1170+
do {
1171+
let all = try await TranscriptReader.readTurns(from: path)
1172+
turns = all
1173+
hasMoreTurns = false
1174+
} catch {
1175+
// Silently fail
1176+
}
1177+
isLoadingMore = false
1178+
}
1179+
11431180
// MARK: - File watcher
11441181

11451182
private func startHistoryWatcher() {

Sources/KanbanCode/CardView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ struct CardView: View {
1212
var onRename: () -> Void = {}
1313
var onCopyResumeCmd: () -> Void = {}
1414
var onCleanupWorktree: () -> Void = {}
15+
var canCleanupWorktree: Bool = true
1516
var onArchive: () -> Void = {}
1617
var onDelete: () -> Void = {}
1718
var availableProjects: [(name: String, path: String)] = []
@@ -183,7 +184,7 @@ struct CardView: View {
183184
Label("Open Issue #\(issue.number)", systemImage: "arrow.up.right.square")
184185
}
185186
}
186-
if card.link.worktreeLink != nil {
187+
if card.link.worktreeLink != nil, canCleanupWorktree {
187188
Divider()
188189
Button(role: .destructive, action: onCleanupWorktree) {
189190
Label("Cleanup Worktree", systemImage: "trash")

Sources/KanbanCode/ContentView.swift

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,16 @@ struct ContentView: View {
158158
NSPasteboard.general.setString(cmd, forType: .string)
159159
},
160160
onCleanupWorktree: { cardId in Task { await cleanupWorktree(cardId: cardId) } },
161+
canCleanupWorktree: { cardId in
162+
guard let card = store.state.cards.first(where: { $0.id == cardId }) else { return false }
163+
return canCleanupWorktree(for: card)
164+
},
161165
onArchiveCard: { cardId in archiveCard(cardId: cardId) },
162166
onDeleteCard: { cardId in pendingDeleteCardId = cardId },
163167
availableProjects: projectList,
164168
onMoveToProject: { cardId, projectPath in
165-
store.dispatch(.moveCardToProject(cardId: cardId, projectPath: projectPath))
169+
let name = projectList.first(where: { $0.path == projectPath })?.name ?? (projectPath as NSString).lastPathComponent
170+
pendingMoveToProject = (cardId: cardId, projectPath: projectPath, projectName: name)
166171
},
167172
onRefreshBacklog: { Task { await store.refreshBacklog() } },
168173
onDropCard: { cardId, column in handleDrop(cardId: cardId, to: column) },
@@ -210,6 +215,7 @@ struct ContentView: View {
210215
onCleanupWorktree: {
211216
Task { await cleanupWorktree(cardId: card.id) }
212217
},
218+
canCleanupWorktree: canCleanupWorktree(for: card),
213219
onDeleteCard: {
214220
pendingDeleteCardId = card.id
215221
},
@@ -230,6 +236,11 @@ struct ContentView: View {
230236
store.dispatch(.setBusy(cardId: card.id, busy: false))
231237
}
232238
},
239+
availableProjects: projectList,
240+
onMoveToProject: { projectPath in
241+
let name = projectList.first(where: { $0.path == projectPath })?.name ?? (projectPath as NSString).lastPathComponent
242+
pendingMoveToProject = (cardId: card.id, projectPath: projectPath, projectName: name)
243+
},
233244
focusTerminal: $shouldFocusTerminal
234245
)
235246
.inspectorColumnWidth(min: 600, ideal: 800, max: 1000)
@@ -441,6 +452,27 @@ struct ContentView: View {
441452
Text("This creates a duplicate session you can resume independently.")
442453
}
443454
}
455+
.alert(
456+
"Move to Project?",
457+
isPresented: Binding(
458+
get: { pendingMoveToProject != nil },
459+
set: { if !$0 { pendingMoveToProject = nil } }
460+
)
461+
) {
462+
Button("Cancel", role: .cancel) {
463+
pendingMoveToProject = nil
464+
}
465+
Button("Move") {
466+
if let pending = pendingMoveToProject {
467+
store.dispatch(.moveCardToProject(cardId: pending.cardId, projectPath: pending.projectPath))
468+
}
469+
pendingMoveToProject = nil
470+
}
471+
} message: {
472+
if let pending = pendingMoveToProject {
473+
Text("Move this card to \(pending.projectName)?")
474+
}
475+
}
444476
.alert(
445477
"Cleanup Worktree?",
446478
isPresented: Binding(
@@ -844,16 +876,12 @@ struct ContentView: View {
844876
private var projectList: [(name: String, path: String)] {
845877
var seen = Set<String>()
846878
var result: [(name: String, path: String)] = []
847-
// Configured projects first
879+
// Only configured projects — discovered paths are auto-assigned,
880+
// "Move to Project" is for intentionally moving between configured projects.
848881
for project in store.state.configuredProjects {
849882
guard seen.insert(project.path).inserted else { continue }
850883
result.append((name: project.name, path: project.path))
851884
}
852-
// Then discovered project paths
853-
for path in store.state.discoveredProjectPaths {
854-
guard seen.insert(path).inserted else { continue }
855-
result.append((name: (path as NSString).lastPathComponent, path: path))
856-
}
857885
return result
858886
}
859887

@@ -1391,6 +1419,19 @@ struct ContentView: View {
13911419
}
13921420
}
13931421

1422+
// MARK: - Worktree cleanup guard
1423+
1424+
/// Whether this card's worktree can be cleaned up — false if another active card depends on it.
1425+
private func canCleanupWorktree(for card: KanbanCodeCard) -> Bool {
1426+
guard let branch = card.link.worktreeLink?.branch else { return false }
1427+
let otherCards = store.state.cards.filter {
1428+
$0.id != card.id
1429+
&& !$0.link.manuallyArchived
1430+
&& $0.link.worktreeLink?.branch == branch
1431+
}
1432+
return otherCards.isEmpty
1433+
}
1434+
13941435
// MARK: - Archive
13951436

13961437
private func archiveCard(cardId: String) {
@@ -1400,8 +1441,9 @@ struct ContentView: View {
14001441
} else {
14011442
store.dispatch(.archiveCard(cardId: cardId))
14021443
// Only offer worktree cleanup if the card has an actual worktree directory
1403-
// (not just a branch reference from discovery or manual linking)
1404-
if let wt = card.link.worktreeLink, !wt.path.isEmpty, wt.path.contains("/.claude/worktrees/") {
1444+
// and no other active card depends on it
1445+
if let wt = card.link.worktreeLink, !wt.path.isEmpty, wt.path.contains("/.claude/worktrees/"),
1446+
canCleanupWorktree(for: card) {
14051447
pendingWorktreeCleanupCardId = cardId
14061448
}
14071449
}
@@ -1670,6 +1712,7 @@ struct ContentView: View {
16701712
@State private var pendingDeleteCardId: String?
16711713
@State private var pendingArchiveCardId: String?
16721714
@State private var pendingForkCardId: String?
1715+
@State private var pendingMoveToProject: (cardId: String, projectPath: String, projectName: String)?
16731716
@State private var pendingWorktreeCleanupCardId: String?
16741717
@State private var shouldFocusTerminal = false
16751718
@State private var keyMonitor: Any?
@@ -1811,29 +1854,39 @@ struct ContentView: View {
18111854
// When forking from a worktree (and not keeping it), use the parent project.
18121855
var forkProjectPath = card.link.projectPath
18131856
var targetDir: String? = nil
1814-
if !keepWorktree, let pp = card.link.projectPath,
1815-
let range = pp.range(of: "/.claude/worktrees/") {
1816-
forkProjectPath = String(pp[..<range.lowerBound])
1817-
// Place the session file in the parent project's session dir
1818-
let encoded = forkProjectPath!.replacingOccurrences(of: "/", with: "-")
1819-
let home = NSHomeDirectory()
1820-
targetDir = "\(home)/.claude/projects/\(encoded)"
1857+
if !keepWorktree {
1858+
// Extract parent project if projectPath is a worktree path
1859+
if let pp = forkProjectPath,
1860+
let range = pp.range(of: "/.claude/worktrees/") {
1861+
forkProjectPath = String(pp[..<range.lowerBound])
1862+
}
1863+
// Always place the forked session in the correct project dir
1864+
// so `claude --resume` can find it from the project root.
1865+
if let fp = forkProjectPath {
1866+
let encoded = fp.replacingOccurrences(of: "/", with: "-")
1867+
let home = NSHomeDirectory()
1868+
targetDir = "\(home)/.claude/projects/\(encoded)"
1869+
}
18211870
}
18221871

18231872
let newSessionId = try await store.sessionStore.forkSession(
18241873
sessionPath: sessionPath, targetDirectory: targetDir
18251874
)
18261875
let dir = targetDir ?? (sessionPath as NSString).deletingLastPathComponent
18271876
let newPath = (dir as NSString).appendingPathComponent("\(newSessionId).jsonl")
1828-
let newLink = Link(
1877+
var newLink = Link(
18291878
name: (card.link.name ?? card.link.displayTitle) + " (fork)",
18301879
projectPath: forkProjectPath,
18311880
column: .waiting,
1832-
lastActivity: .now,
1881+
lastActivity: card.link.lastActivity,
18331882
source: .discovered,
18341883
sessionLink: SessionLink(sessionId: newSessionId, sessionPath: newPath),
18351884
worktreeLink: keepWorktree ? card.link.worktreeLink : nil
18361885
)
1886+
// Mark "no worktree" as intentional so reconciler doesn't re-attach it
1887+
if !keepWorktree && card.link.worktreeLink != nil {
1888+
newLink.manualOverrides.worktreePath = true
1889+
}
18371890
store.dispatch(.createManualTask(newLink))
18381891
store.dispatch(.selectCard(cardId: newLink.id))
18391892
} catch {

Sources/KanbanCode/DragAndDrop.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ struct DroppableColumnView: View {
3434
var onForkCard: (String) -> Void = { _ in }
3535
var onCopyResumeCmd: (String) -> Void = { _ in }
3636
var onCleanupWorktree: (String) -> Void = { _ in }
37+
var canCleanupWorktree: (String) -> Bool = { _ in true }
3738
var onDeleteCard: (String) -> Void = { _ in }
3839
var availableProjects: [(name: String, path: String)] = []
3940
var onMoveToProject: (String, String) -> Void = { _, _ in } // (cardId, projectPath)
@@ -67,6 +68,7 @@ struct DroppableColumnView: View {
6768
},
6869
onCopyResumeCmd: { onCopyResumeCmd(card.id) },
6970
onCleanupWorktree: { onCleanupWorktree(card.id) },
71+
canCleanupWorktree: canCleanupWorktree(card.id),
7072
onArchive: { onArchiveCard(card.id) },
7173
onDelete: { onDeleteCard(card.id) },
7274
availableProjects: availableProjects,

0 commit comments

Comments
 (0)