Skip to content

Commit 43be5b3

Browse files
committed
Preserve tab/worktree selection and stabilize focused actions
1 parent f8a44d9 commit 43be5b3

6 files changed

Lines changed: 166 additions & 31 deletions

File tree

aizen/Views/Chat/ChatActions.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,20 @@
77

88
import SwiftUI
99

10-
struct ChatActions {
11-
let cycleModeForward: () -> Void
10+
final class ChatActions {
11+
private var cycleModeForwardHandler: (() -> Void)?
12+
13+
func configure(cycleModeForward: @escaping () -> Void) {
14+
cycleModeForwardHandler = cycleModeForward
15+
}
16+
17+
func clear() {
18+
cycleModeForwardHandler = nil
19+
}
20+
21+
func cycleModeForward() {
22+
cycleModeForwardHandler?()
23+
}
1224
}
1325

1426
struct ChatActionsKey: FocusedValueKey {

aizen/Views/Chat/ChatSessionView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ struct ChatSessionView: View {
3030
@State private var fileToOpenInEditor: String?
3131
@State private var autocompleteWindow: AutocompleteWindowController?
3232
@State private var keyMonitor: Any?
33+
@State private var chatActions = ChatActions()
3334
@State private var isWindowResizing = false
3435
@State private var wasNearBottomBeforeResize = true
3536

@@ -196,7 +197,7 @@ struct ChatSessionView: View {
196197
.background(WindowResizeObserver(isResizing: $isWindowResizing))
197198
.focusedSceneValue(
198199
\.chatActions,
199-
isSelected ? ChatActions(cycleModeForward: viewModel.cycleModeForward) : nil
200+
isSelected ? chatActions : nil
200201
)
201202
.onChange(of: isLayoutResizing) { _, resizing in
202203
if resizing {
@@ -215,6 +216,7 @@ struct ChatSessionView: View {
215216
inputText = draft
216217
}
217218
if isSelected {
219+
chatActions.configure(cycleModeForward: viewModel.cycleModeForward)
218220
viewModel.setupAgentSession()
219221
setupAutocompleteWindow()
220222
NotificationCenter.default.post(name: .chatViewDidAppear, object: nil)
@@ -228,13 +230,15 @@ struct ChatSessionView: View {
228230
autocompleteWindow?.dismiss()
229231
NotificationCenter.default.post(name: .chatViewDidDisappear, object: nil)
230232
stopKeyMonitorIfNeeded()
233+
chatActions.clear()
231234
if showingVoiceRecording {
232235
viewModel.audioService.cancelRecording()
233236
showingVoiceRecording = false
234237
}
235238
}
236239
.onChange(of: isSelected) { _, selected in
237240
if selected {
241+
chatActions.configure(cycleModeForward: viewModel.cycleModeForward)
238242
viewModel.setupAgentSession()
239243
setupAutocompleteWindow()
240244
NotificationCenter.default.post(name: .chatViewDidAppear, object: nil)
@@ -245,6 +249,7 @@ struct ChatSessionView: View {
245249
autocompleteWindow?.dismiss()
246250
NotificationCenter.default.post(name: .chatViewDidDisappear, object: nil)
247251
stopKeyMonitorIfNeeded()
252+
chatActions.clear()
248253
if showingVoiceRecording {
249254
viewModel.audioService.cancelRecording()
250255
showingVoiceRecording = false

aizen/Views/ContentView.swift

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ struct ContentView: View {
4141
@AppStorage("selectedWorkspaceId") private var selectedWorkspaceId: String?
4242
@AppStorage("selectedRepositoryId") private var selectedRepositoryId: String?
4343
@AppStorage("selectedWorktreeId") private var selectedWorktreeId: String?
44+
@AppStorage("selectedWorktreeByRepository") private var selectedWorktreeByRepositoryData: String = "{}"
4445

4546
init(context: NSManagedObjectContext, repositoryManager: RepositoryManager, gitChangesContext: Binding<GitChangesContext?>) {
4647
self.repositoryManager = repositoryManager
@@ -98,6 +99,7 @@ struct ContentView: View {
9899
selectedWorktree = nextWorktree
99100
}
100101
)
102+
.id(worktree.id)
101103
} else {
102104
placeholderView(
103105
titleKey: "contentView.selectWorktree",
@@ -142,13 +144,24 @@ struct ContentView: View {
142144
selectedRepository = repositories.first(where: { $0.id == uuid })
143145
}
144146

145-
// Restore selected worktree from persistent storage
146-
if selectedWorktree == nil,
147-
let worktreeId = selectedWorktreeId,
148-
let uuid = UUID(uuidString: worktreeId),
149-
let repository = selectedRepository {
147+
// Restore selected worktree from repository-specific storage first.
148+
if selectedWorktree == nil, let repository = selectedRepository {
150149
let worktrees = (repository.worktrees as? Set<Worktree>) ?? []
151-
selectedWorktree = worktrees.first(where: { $0.id == uuid })
150+
if let restoredWorktreeId = getStoredWorktreeId(for: repository) {
151+
selectedWorktree = worktrees.first(where: { $0.id == restoredWorktreeId && !$0.isDeleted })
152+
}
153+
154+
// Fallback to global selection if repository-specific value is missing.
155+
if selectedWorktree == nil,
156+
let worktreeId = selectedWorktreeId,
157+
let uuid = UUID(uuidString: worktreeId) {
158+
selectedWorktree = worktrees.first(where: { $0.id == uuid && !$0.isDeleted })
159+
}
160+
161+
if selectedWorktree == nil {
162+
let candidates = worktrees.filter { !$0.isDeleted }
163+
selectedWorktree = candidates.first(where: { $0.isPrimary }) ?? candidates.first
164+
}
152165
}
153166

154167
if !hasShownOnboarding {
@@ -191,16 +204,25 @@ struct ContentView: View {
191204
}
192205
}
193206

194-
// Auto-select primary worktree when repository changes
207+
// Restore previously selected worktree for this repository.
195208
let worktrees = (repo.worktrees as? Set<Worktree>) ?? []
196-
selectedWorktree = worktrees.first(where: { $0.isPrimary })
209+
if let restoredWorktreeId = getStoredWorktreeId(for: repo),
210+
let restoredWorktree = worktrees.first(where: { $0.id == restoredWorktreeId && !$0.isDeleted }) {
211+
selectedWorktree = restoredWorktree
212+
} else {
213+
let candidates = worktrees.filter { !$0.isDeleted }
214+
selectedWorktree = candidates.first(where: { $0.isPrimary }) ?? candidates.first
215+
}
197216
}
198217
}
199218
.onChange(of: selectedWorktree) { _, newValue in
200219
selectedWorktreeId = newValue?.id?.uuidString
201220

202221
if let newWorktree = newValue, !newWorktree.isDeleted {
203222
previousWorktree = newWorktree
223+
if let repository = selectedRepository {
224+
storeWorktreeSelection(newWorktree.id, for: repository)
225+
}
204226
// Update worktree access asynchronously to avoid blocking UI
205227
Task { @MainActor in
206228
try? repositoryManager.updateWorktreeAccess(newWorktree)
@@ -283,6 +305,40 @@ struct ContentView: View {
283305
controller.showWindow(nil)
284306
}
285307

308+
private func decodeSelectedWorktreeByRepository() -> [String: String] {
309+
guard let data = selectedWorktreeByRepositoryData.data(using: .utf8),
310+
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
311+
return [:]
312+
}
313+
return decoded
314+
}
315+
316+
private func encodeSelectedWorktreeByRepository(_ map: [String: String]) {
317+
guard let encoded = try? JSONEncoder().encode(map),
318+
let json = String(data: encoded, encoding: .utf8) else {
319+
return
320+
}
321+
selectedWorktreeByRepositoryData = json
322+
}
323+
324+
private func getStoredWorktreeId(for repository: Repository) -> UUID? {
325+
guard let repositoryId = repository.id?.uuidString else { return nil }
326+
let map = decodeSelectedWorktreeByRepository()
327+
guard let worktreeIdString = map[repositoryId] else { return nil }
328+
return UUID(uuidString: worktreeIdString)
329+
}
330+
331+
private func storeWorktreeSelection(_ worktreeId: UUID?, for repository: Repository) {
332+
guard let repositoryId = repository.id?.uuidString else { return }
333+
var map = decodeSelectedWorktreeByRepository()
334+
if let worktreeId {
335+
map[repositoryId] = worktreeId.uuidString
336+
} else {
337+
map.removeValue(forKey: repositoryId)
338+
}
339+
encodeSelectedWorktreeByRepository(map)
340+
}
341+
286342
private func quickSwitchToPreviousWorktree() {
287343
let request: NSFetchRequest<Worktree> = Worktree.fetchRequest()
288344
request.sortDescriptors = [NSSortDescriptor(keyPath: \Worktree.lastAccessed, ascending: false)]

aizen/Views/Terminal/Components/SplitTerminalView.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ struct SplitTerminalView: View {
3939
@State private var focusedPaneVoiceRecording = false
4040
@State private var voiceAction: (paneId: String, action: VoiceAction)?
4141
@State private var focusRequestVersion: Int = 0
42+
@State private var splitActions = TerminalSplitActions()
4243
@AppStorage("terminalSessionPersistence") private var sessionPersistence = false
4344

4445
private enum CloseAction {
@@ -87,6 +88,11 @@ struct SplitTerminalView: View {
8788
applyTitleForFocusedPane()
8889
focusRequestVersion += 1
8990
}
91+
splitActions.configure(
92+
splitHorizontal: splitHorizontal,
93+
splitVertical: splitVertical,
94+
closePane: closePane
95+
)
9096
if keyMonitor == nil {
9197
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
9298
handleVoiceShortcut(event)
@@ -101,15 +107,12 @@ struct SplitTerminalView: View {
101107
}
102108
}
103109
// Only set split actions for the currently selected/visible session
104-
.focusedSceneValue(\.terminalSplitActions, isSelected ? TerminalSplitActions(
105-
splitHorizontal: splitHorizontal,
106-
splitVertical: splitVertical,
107-
closePane: closePane
108-
) : nil)
110+
.focusedSceneValue(\.terminalSplitActions, isSelected ? splitActions : nil)
109111
.onDisappear {
110112
layoutSaveTask?.cancel()
111113
focusSaveTask?.cancel()
112114
contextSaveTask?.cancel()
115+
splitActions.clear()
113116
if let monitor = keyMonitor {
114117
NSEvent.removeMonitor(monitor)
115118
keyMonitor = nil

aizen/Views/Terminal/Components/TerminalSplitActions.swift

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,38 @@ import SwiftUI
99

1010
// MARK: - Terminal Split Actions (for keyboard shortcuts)
1111

12-
struct TerminalSplitActions {
13-
let splitHorizontal: () -> Void
14-
let splitVertical: () -> Void
15-
let closePane: () -> Void
12+
final class TerminalSplitActions {
13+
private var splitHorizontalHandler: (() -> Void)?
14+
private var splitVerticalHandler: (() -> Void)?
15+
private var closePaneHandler: (() -> Void)?
16+
17+
func configure(
18+
splitHorizontal: @escaping () -> Void,
19+
splitVertical: @escaping () -> Void,
20+
closePane: @escaping () -> Void
21+
) {
22+
splitHorizontalHandler = splitHorizontal
23+
splitVerticalHandler = splitVertical
24+
closePaneHandler = closePane
25+
}
26+
27+
func clear() {
28+
splitHorizontalHandler = nil
29+
splitVerticalHandler = nil
30+
closePaneHandler = nil
31+
}
32+
33+
func splitHorizontal() {
34+
splitHorizontalHandler?()
35+
}
36+
37+
func splitVertical() {
38+
splitVerticalHandler?()
39+
}
40+
41+
func closePane() {
42+
closePaneHandler?()
43+
}
1644
}
1745

1846
private struct TerminalSplitActionsKey: FocusedValueKey {

aizen/Views/Worktree/WorktreeDetailView.swift

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ struct WorktreeDetailView: View {
4141
@State private var fileSearchWindowController: FileSearchWindowController?
4242
@State private var fileToOpenFromSearch: String?
4343
@State private var cachedTerminalBackgroundColor: Color?
44+
@State private var hasLoadedTabState = false
4445

4546
init(worktree: Worktree, repositoryManager: RepositoryManager, tabStateManager: WorktreeTabStateManager, gitChangesContext: Binding<GitChangesContext?>, onWorktreeDeleted: ((Worktree?) -> Void)? = nil) {
4647
self.worktree = worktree
@@ -418,10 +419,11 @@ struct WorktreeDetailView: View {
418419
}
419420

420421
private func navigateToChatSession(_ sessionId: UUID) {
422+
guard let worktreeId = worktree.id else { return }
421423
// Fetch session directly to check if it belongs to this worktree
422424
// (avoids stale relationship cache after reattachment)
423425
let request: NSFetchRequest<ChatSession> = ChatSession.fetchRequest()
424-
request.predicate = NSPredicate(format: "id == %@ AND worktree.id == %@", sessionId as CVarArg, worktree.id! as CVarArg)
426+
request.predicate = NSPredicate(format: "id == %@ AND worktree.id == %@", sessionId as CVarArg, worktreeId as CVarArg)
425427
request.fetchLimit = 1
426428

427429
if let _ = try? worktree.managedObjectContext?.fetch(request).first {
@@ -440,10 +442,11 @@ struct WorktreeDetailView: View {
440442
guard let sessionId = notification.userInfo?["chatSessionId"] as? UUID else {
441443
return
442444
}
445+
guard let worktreeId = worktree.id else { return }
443446
// Fetch session directly to verify it belongs to this worktree
444447
// (avoids stale relationship cache after reattachment)
445448
let request: NSFetchRequest<ChatSession> = ChatSession.fetchRequest()
446-
request.predicate = NSPredicate(format: "id == %@ AND worktree.id == %@", sessionId as CVarArg, worktree.id! as CVarArg)
449+
request.predicate = NSPredicate(format: "id == %@ AND worktree.id == %@", sessionId as CVarArg, worktreeId as CVarArg)
447450
request.fetchLimit = 1
448451

449452
if let _ = try? worktree.managedObjectContext?.fetch(request).first {
@@ -471,9 +474,16 @@ struct WorktreeDetailView: View {
471474
// Store attachment (user can add context before sending)
472475
ChatSessionManager.shared.setPendingAttachments([attachment], for: sessionId)
473476

474-
// Switch to chat tab and select the session
475-
selectedTab = "chat"
476-
viewModel.selectedChatSessionId = sessionId
477+
if doesChatSessionBelongToCurrentWorktree(sessionId) {
478+
selectedTab = "chat"
479+
viewModel.selectedChatSessionId = sessionId
480+
} else {
481+
NotificationCenter.default.post(
482+
name: .navigateToChatSession,
483+
object: nil,
484+
userInfo: ["chatSessionId": sessionId]
485+
)
486+
}
477487
}
478488

479489
private func handleSwitchToChat(_ notification: Notification) {
@@ -482,9 +492,28 @@ struct WorktreeDetailView: View {
482492
return
483493
}
484494

485-
// Switch to chat tab and select the session
486-
selectedTab = "chat"
487-
viewModel.selectedChatSessionId = sessionId
495+
if doesChatSessionBelongToCurrentWorktree(sessionId) {
496+
selectedTab = "chat"
497+
viewModel.selectedChatSessionId = sessionId
498+
} else {
499+
NotificationCenter.default.post(
500+
name: .navigateToChatSession,
501+
object: nil,
502+
userInfo: ["chatSessionId": sessionId]
503+
)
504+
}
505+
}
506+
507+
private func doesChatSessionBelongToCurrentWorktree(_ sessionId: UUID) -> Bool {
508+
guard let worktreeId = worktree.id else { return false }
509+
let request: NSFetchRequest<ChatSession> = ChatSession.fetchRequest()
510+
request.predicate = NSPredicate(
511+
format: "id == %@ AND worktree.id == %@",
512+
sessionId as CVarArg,
513+
worktreeId as CVarArg
514+
)
515+
request.fetchLimit = 1
516+
return ((try? worktree.managedObjectContext?.fetch(request).first) != nil)
488517
}
489518

490519
var body: some View {
@@ -501,7 +530,6 @@ struct WorktreeDetailView: View {
501530
.toolbarBackground(.visible, for: .windowToolbar)
502531
.toast()
503532
.onAppear {
504-
validateSelectedTab()
505533
cachedTerminalBackgroundColor = getTerminalBackgroundColor()
506534
}
507535
.onChange(of: terminalThemeName) { _, _ in
@@ -527,17 +555,20 @@ struct WorktreeDetailView: View {
527555
trailingToolbarItems
528556
}
529557
.task(id: worktree.id) {
530-
await setupGitMonitoring()
531-
xcodeBuildManager.detectProject(at: worktree.path ?? "")
558+
hasLoadedTabState = false
532559
loadTabState()
533560
validateSelectedTab()
561+
hasLoadedTabState = true
562+
await setupGitMonitoring()
563+
xcodeBuildManager.detectProject(at: worktree.path ?? "")
534564
}
535565
}
536566

537567
@ViewBuilder
538568
private var navigationContent: some View {
539569
contentWithBasicModifiers
540570
.onChange(of: selectedTab) { _, _ in
571+
guard hasLoadedTabState else { return }
541572
saveTabState()
542573
}
543574
.onChange(of: viewModel.selectedChatSessionId) { _, newValue in

0 commit comments

Comments
 (0)