From 183acf8138d7055f42b534ded4756ab8fd6e3630 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 22:58:02 +0300 Subject: [PATCH 1/9] Focus existing tab when reopening a remote session instead of duplicating - MainFeature.Action.newRemoteSession gains optional session: RemoteSession? parameter - Reducer looks up tabs by remoteSessionID before creating a new one; if found, focuses it - MainFeature.State.init respects a non-empty tabs argument instead of always creating default tab - AppFeature.openRemoteSession passes the session through to MainFeature - TerminalFeature.start adds a catch handler so gRPC connection errors update connectionState instead of leaking as unhandled throws (fixes test isolation) - SessionSwitchTerminalTests: all 6 tests now pass --- .../TerminalFeature/TerminalFeature.swift | 9 + MacApp/Relay/AppFeature.swift | 14 +- MacApp/Relay/MainFeature.swift | 49 +++-- .../SessionSwitchTerminalTests.swift | 192 ++++++++++++++++++ 4 files changed, 239 insertions(+), 25 deletions(-) create mode 100644 MacApp/RelayTests/SessionSwitchTerminalTests.swift diff --git a/MacApp/Packages/TerminalFeature/Sources/TerminalFeature/TerminalFeature.swift b/MacApp/Packages/TerminalFeature/Sources/TerminalFeature/TerminalFeature.swift index 68d2ac1..4eca050 100644 --- a/MacApp/Packages/TerminalFeature/Sources/TerminalFeature/TerminalFeature.swift +++ b/MacApp/Packages/TerminalFeature/Sources/TerminalFeature/TerminalFeature.swift @@ -69,6 +69,8 @@ public struct TerminalFeature { /// Внутренние — результат создания сессии. case _sessionCreated(UUID) + /// Внутренние — сессия не смогла подключиться (gRPC / PTY ошибка). + case _connectionFailed(String) } // MARK: - Dependencies @@ -137,6 +139,8 @@ public struct TerminalFeature { for await event in eventStream { await send(.terminalEvent(event)) } + } catch: { error, send in + await send(._connectionFailed(error.localizedDescription)) } .cancellable(id: CancelID.session) @@ -159,6 +163,11 @@ public struct TerminalFeature { state.isRunning = true return .none + case let ._connectionFailed(description): + state.isRunning = false + state.connectionState = .disconnected(reason: .networkError(description)) + return .cancel(id: CancelID.session) + case let .terminalEvent(event): return reduce(state: &state, event: event) } diff --git a/MacApp/Relay/AppFeature.swift b/MacApp/Relay/AppFeature.swift index 1a6aeb7..5cc7698 100644 --- a/MacApp/Relay/AppFeature.swift +++ b/MacApp/Relay/AppFeature.swift @@ -202,7 +202,7 @@ struct AppFeature { guard session.state == .running else { return .none } state.cloudNavigation = nil activeRESTClient.setValue(nil) - return openRemoteSession(state: &state, server: server) + return openRemoteSession(state: &state, server: server, session: session) case .cloudNavigation(.dismiss): activeRESTClient.setValue(nil) @@ -240,21 +240,23 @@ struct AppFeature { // MARK: - Helpers - /// Switches to Main (if still on Welcome) and opens a new remote tab for - /// the given server. + /// Switches to Main (if still on Welcome) and opens a remote tab for the given + /// server. If `session` is provided and a tab for it already exists, focuses + /// that tab instead of creating a duplicate. private func openRemoteSession( state: inout State, - server: ServerCredential + server: ServerCredential, + session: RemoteSession? = nil ) -> Effect { switch state.route { case .welcome: state.route = .main(MainFeature.State()) return .concatenate( .send(.main(._appInitialized)), - .send(.main(.newRemoteSession(server: server))) + .send(.main(.newRemoteSession(server: server, session: session))) ) case .main: - return .send(.main(.newRemoteSession(server: server))) + return .send(.main(.newRemoteSession(server: server, session: session))) } } } diff --git a/MacApp/Relay/MainFeature.swift b/MacApp/Relay/MainFeature.swift index a380cb1..3b68144 100644 --- a/MacApp/Relay/MainFeature.swift +++ b/MacApp/Relay/MainFeature.swift @@ -44,18 +44,17 @@ struct MainFeature { self.worktree = worktree self.isPickingDirectory = isPickingDirectory - // Создаём default сессию (системную) при инициализации - let defaultTab = Tab( - title: "Terminal", - kind: .default - ) - var defaultTabs = TabFeature.State( - tabs: [defaultTab], - selectedTabID: defaultTab.id - ) - defaultTabs.tabs = [defaultTab] - defaultTabs.selectedTabID = defaultTab.id - self.tabs = defaultTabs + // Use caller-provided tabs when non-empty (e.g. in tests or pre-populated state). + // Otherwise create the default "Terminal" tab for a fresh launch. + if tabs.tabs.isEmpty { + let defaultTab = Tab(title: "Terminal", kind: .default) + self.tabs = TabFeature.State( + tabs: [defaultTab], + selectedTabID: defaultTab.id + ) + } else { + self.tabs = tabs + } } } @@ -72,7 +71,9 @@ struct MainFeature { /// Открыть plain shell-вкладку без агента (Cmd+T). case newShellTab /// Open a remote terminal tab connected to the specified server. - case newRemoteSession(server: ServerCredential) + /// If `session` is provided and a tab for that session already exists, + /// the existing tab is focused instead of creating a duplicate. + case newRemoteSession(server: ServerCredential, session: RemoteSession? = nil) case _newSessionDirectoryPicked(URL) case _setPickingDirectory(Bool) case _projectOpened(projectPath: String, projectName: String, defaultTabID: UUID, directory: URL) @@ -184,7 +185,19 @@ struct MainFeature { ?? FileManager.default.homeDirectoryForCurrentUser return .send(.orchestrator(.newShellSession(workingDirectory: workingDirectory))) - case let .newRemoteSession(server): + case let .newRemoteSession(server, session): + // If a tab for this remote session already exists — focus it, don't duplicate. + if let sessionID = session?.id, + let existing = state.tabs.tabs.first(where: { $0.remoteSessionID == sessionID }) { + state.tabs.selectedTabID = existing.id + return .none + } + // No existing tab — create a new one now, before forwarding to orchestrator. + let tabTitle = session?.branch ?? server.displayName + var remoteTab = Tab(title: tabTitle) + remoteTab.remoteSessionID = session?.id + state.tabs.tabs.append(remoteTab) + state.tabs.selectedTabID = remoteTab.id return .send(.orchestrator(.newRemoteSession(server: server))) case let ._newSessionDirectoryPicked(url): @@ -269,11 +282,9 @@ struct MainFeature { } return .none - case let .orchestrator(.newRemoteSession(server)): - // Create a tab for the remote terminal session - let remoteTab = Tab(title: server.displayName) - state.tabs.tabs.append(remoteTab) - state.tabs.selectedTabID = remoteTab.id + case .orchestrator(.newRemoteSession): + // Tab creation and focus are handled in the .newRemoteSession action above, + // before the orchestrator is invoked. Nothing to do here. return .none case let .orchestrator(.sessions(.element(id: sessionID, action: ._agentStarted))): diff --git a/MacApp/RelayTests/SessionSwitchTerminalTests.swift b/MacApp/RelayTests/SessionSwitchTerminalTests.swift new file mode 100644 index 0000000..5bb8423 --- /dev/null +++ b/MacApp/RelayTests/SessionSwitchTerminalTests.swift @@ -0,0 +1,192 @@ +import ComposableArchitecture +import Foundation +import PaneManager +import RemoteTerminal +import SharedModels +import Testing +@testable import Relay + +// Tests for: при выборе remote-сессии с открытым терминалом активная вкладка +// должна переключаться на неё, а не создавать дублирующую. +// +// Эти тесты описывают ОЖИДАЕМОЕ поведение (TDD red state): +// они провалятся до реализации фикса в AppFeature / MainFeature. + +@Suite("Session Switch Terminal") +@MainActor +struct SessionSwitchTerminalTests { + + // MARK: - Helpers + + private func makeSession( + id: String = "sess-001", + branch: String = "main", + state: SessionState = .running + ) -> RemoteSession { + RemoteSession( + id: id, + projectId: "proj-001", + branch: branch, + state: state, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 60) + ) + } + + private func makeCredential(name: String = "My Server") -> ServerCredential { + ServerCredential( + host: "localhost", + port: 50051, + displayName: name + ) + } + + // MARK: - Tab.remoteSessionID field + + @Test("Tab has remoteSessionID field") + func tabHasRemoteSessionID() { + var tab = Tab(title: "Session A") + tab.remoteSessionID = "sess-abc" + #expect(tab.remoteSessionID == "sess-abc") + } + + @Test("Tab.remoteSessionID is nil by default") + func tabRemoteSessionIDDefaultNil() { + let tab = Tab(title: "Default") + #expect(tab.remoteSessionID == nil) + } + + // MARK: - MainFeature: open terminal sets remoteSessionID on new tab + + @Test("Opening remote session creates tab with remoteSessionID set") + func openRemoteSessionSetsRemoteSessionID() async { + let server = makeCredential() + let session = makeSession(id: "sess-001", branch: "main") + + let store = TestStore( + initialState: MainFeature.State() + ) { + MainFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.date = .constant(Date(timeIntervalSince1970: 0)) + } + store.exhaustivity = .off + + await store.send(.newRemoteSession(server: server, session: session)) + + store.assert { + let tab = $0.tabs.tabs.last + #expect(tab?.remoteSessionID == "sess-001") + #expect($0.tabs.selectedTabID == tab?.id) + } + } + + // MARK: - MainFeature: focus existing tab instead of creating duplicate + + @Test("Opening terminal for session with existing tab focuses it, no new tab") + func openTerminalFocusesExistingTab() async { + let server = makeCredential() + let session = makeSession(id: "sess-002", branch: "feature/auth") + + // Pre-existing tab already bound to this session. + // Pass a non-empty TabFeature.State so MainFeature.State.init uses it as-is. + var existingTab = Tab(title: "feature/auth") + existingTab.remoteSessionID = "sess-002" + + let store = TestStore( + initialState: MainFeature.State( + tabs: TabFeature.State( + tabs: [existingTab], + selectedTabID: nil // some other tab is active + ) + ) + ) { + MainFeature() + } withDependencies: { + $0.date = .constant(Date(timeIntervalSince1970: 0)) + } + store.exhaustivity = .off + + await store.send(.newRemoteSession(server: server, session: session)) + + store.assert { + // No new tab created + #expect($0.tabs.tabs.count == 1) + // Existing tab is now active + #expect($0.tabs.selectedTabID == existingTab.id) + } + } + + // MARK: - MainFeature: different sessions get separate tabs + + @Test("Two different sessions get two separate tabs") + func differentSessionsGetSeparateTabs() async { + let server = makeCredential() + let sessionA = makeSession(id: "sess-A", branch: "main") + let sessionB = makeSession(id: "sess-B", branch: "feature/auth") + + // Start with a single placeholder tab so MainFeature.State.init does not + // inject its own default tab (which would inflate the count). + let placeholder = Tab(title: "placeholder") + let store = TestStore( + initialState: MainFeature.State( + tabs: TabFeature.State(tabs: [placeholder], selectedTabID: nil) + ) + ) { + MainFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.date = .constant(Date(timeIntervalSince1970: 0)) + } + store.exhaustivity = .off + + await store.send(.newRemoteSession(server: server, session: sessionA)) + await store.send(.newRemoteSession(server: server, session: sessionB)) + + store.assert { + // placeholder + 2 remote tabs = 3; check only that the remote ones are distinct + let ids = $0.tabs.tabs.compactMap(\.remoteSessionID) + #expect(Set(ids) == Set(["sess-A", "sess-B"])) + #expect(ids.count == 2) + } + } + + // MARK: - MainFeature: switching back to session A after B focuses A's tab + + @Test("Switching back to session A focuses its existing tab") + func switchBackToSessionAFocusesItsTab() async { + let server = makeCredential() + let sessionA = makeSession(id: "sess-A", branch: "main") + let sessionB = makeSession(id: "sess-B", branch: "feature/auth") + + // Start with a placeholder tab so init does not inject an extra default tab. + let placeholder = Tab(title: "placeholder") + let store = TestStore( + initialState: MainFeature.State( + tabs: TabFeature.State(tabs: [placeholder], selectedTabID: nil) + ) + ) { + MainFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.date = .constant(Date(timeIntervalSince1970: 0)) + } + store.exhaustivity = .off + + // Open A, then B (B becomes active) + await store.send(.newRemoteSession(server: server, session: sessionA)) + await store.send(.newRemoteSession(server: server, session: sessionB)) + + // "Switch back" to A — should focus A's tab, not create a third remote tab + await store.send(.newRemoteSession(server: server, session: sessionA)) + + store.assert { + // Only 2 remote session tabs — no duplicate was created for sess-A + let remoteIDs = $0.tabs.tabs.compactMap(\.remoteSessionID) + #expect(remoteIDs.count == 2) + let tabA = $0.tabs.tabs.first(where: { $0.remoteSessionID == "sess-A" }) + #expect($0.tabs.selectedTabID == tabA?.id) + } + } +} From db7c027ea116b5eada0dab80116dda7ececd4626 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 23:12:28 +0300 Subject: [PATCH 2/9] Extend session-switch fix to local sessions (newClaudeSession) newClaudeSession now checks for an existing tab with matching worktreeID before creating a new one. New tabs created via orchestrator(.newSession) receive worktreeID from the current worktree selection so future lookups can find them. Co-Authored-By: Claude Sonnet 4.6 --- .../PaneManager/Sources/PaneManager/TabFeature.swift | 6 ++++++ MacApp/Relay/MainFeature.swift | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/MacApp/Packages/PaneManager/Sources/PaneManager/TabFeature.swift b/MacApp/Packages/PaneManager/Sources/PaneManager/TabFeature.swift index a8a11d2..f47abcf 100644 --- a/MacApp/Packages/PaneManager/Sources/PaneManager/TabFeature.swift +++ b/MacApp/Packages/PaneManager/Sources/PaneManager/TabFeature.swift @@ -20,6 +20,10 @@ public struct Tab: Equatable, Identifiable, Sendable { /// ID worktree если сессия привязана к worktree. public var worktreeID: UUID? + /// ID cloud-сессии (RemoteSession.id). Используется для поиска + /// существующей вкладки при повторном "Open Terminal" на той же сессии. + public var remoteSessionID: String? + /// Тип сессии: системная или созданная пользователем. public var kind: SessionKind @@ -29,6 +33,7 @@ public struct Tab: Equatable, Identifiable, Sendable { terminalSessionID: UUID? = nil, agentSessionID: UUID? = nil, worktreeID: UUID? = nil, + remoteSessionID: String? = nil, kind: SessionKind = .userCreated ) { self.id = id @@ -36,6 +41,7 @@ public struct Tab: Equatable, Identifiable, Sendable { self.terminalSessionID = terminalSessionID self.agentSessionID = agentSessionID self.worktreeID = worktreeID + self.remoteSessionID = remoteSessionID self.kind = kind } } diff --git a/MacApp/Relay/MainFeature.swift b/MacApp/Relay/MainFeature.swift index 3b68144..bf851e7 100644 --- a/MacApp/Relay/MainFeature.swift +++ b/MacApp/Relay/MainFeature.swift @@ -170,6 +170,11 @@ struct MainFeature { case .newClaudeSession: if let selectedID = state.worktree.selectedWorktreeID, let detail = state.worktree.worktrees[id: selectedID] { + // Focus existing tab for this worktree instead of duplicating. + if let existing = state.tabs.tabs.first(where: { $0.worktreeID == selectedID }) { + state.tabs.selectedTabID = existing.id + return .none + } let worktreePath = detail.worktree.path return .run { [worktreePath] send in await send(.orchestrator(.newSession(workingDirectory: worktreePath))) @@ -219,11 +224,12 @@ struct MainFeature { // MARK: Orchestrator side-effects case let .orchestrator(.newSession(workingDirectory, _)): - // Создаём вкладку для новой сессии + // Создаём вкладку для новой сессии, привязываем к текущему worktree. let tab = Tab( title: workingDirectory.lastPathComponent.isEmpty ? "Terminal" - : workingDirectory.lastPathComponent + : workingDirectory.lastPathComponent, + worktreeID: state.worktree.selectedWorktreeID ) state.tabs.tabs.append(tab) state.tabs.selectedTabID = tab.id From 6445919d1cd3dce6c57c8fa6a2c1d907b0829cb7 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 23:17:27 +0300 Subject: [PATCH 3/9] Switch active tab when worktree is selected in sidebar MainFeature now intercepts selectWorktree action and focuses the tab bound to the selected worktree (worktreeID match). Co-Authored-By: Claude Sonnet 4.6 --- MacApp/Relay/MainFeature.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/MacApp/Relay/MainFeature.swift b/MacApp/Relay/MainFeature.swift index bf851e7..2ce5981 100644 --- a/MacApp/Relay/MainFeature.swift +++ b/MacApp/Relay/MainFeature.swift @@ -310,6 +310,13 @@ struct MainFeature { case .orchestrator: return .none + case let .worktree(.selectWorktree(id)): + // Switching worktree → focus the tab bound to it, if one exists. + if let id, let existing = state.tabs.tabs.first(where: { $0.worktreeID == id }) { + state.tabs.selectedTabID = existing.id + } + return .none + case .worktree: return .none From d5a27a387f1b3ae6fff1b6a48ad705803a28da40 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 23:25:10 +0300 Subject: [PATCH 4/9] Set worktreeID on shell tabs; bind default tab to main worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell tabs (newShellTab, newShellSession) now receive worktreeID from selectedWorktreeID. Default tab is bound to the main worktree when its path matches the project root — both eagerly (on shell session start) and lazily (on selectWorktree with path fallback). Co-Authored-By: Claude Sonnet 4.6 --- MacApp/Relay/MainFeature.swift | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/MacApp/Relay/MainFeature.swift b/MacApp/Relay/MainFeature.swift index 2ce5981..aa8aa50 100644 --- a/MacApp/Relay/MainFeature.swift +++ b/MacApp/Relay/MainFeature.swift @@ -252,19 +252,26 @@ struct MainFeature { case let .orchestrator(.newShellSession(workingDirectory, kind)): // Для default shell сессии (kind: .default) — не создаём новую вкладку, - // привязываем к существующему default tab + // привязываем к существующему default tab. + // Привязываем default tab к worktree по пути (main worktree = project root). if kind == .default, let defaultTab = state.tabs.tabs.first(where: { $0.kind == .default }) { + if state.tabs.tabs[id: defaultTab.id]?.worktreeID == nil, + let mainWorktree = state.worktree.worktrees.first(where: { + $0.worktree.path.path == workingDirectory.path + }) { + state.tabs.tabs[id: defaultTab.id]?.worktreeID = mainWorktree.id + } state.tabs.selectedTabID = defaultTab.id - // Worktree sidebar загружается из _openProject / _appInitialized return .none } - // Создаём вкладку для plain shell сессии + // Создаём вкладку для plain shell сессии, привязываем к текущему worktree. let shellTab = Tab( title: workingDirectory.lastPathComponent.isEmpty ? "Shell" - : workingDirectory.lastPathComponent + : workingDirectory.lastPathComponent, + worktreeID: state.worktree.selectedWorktreeID ) state.tabs.tabs.append(shellTab) state.tabs.selectedTabID = shellTab.id @@ -311,9 +318,20 @@ struct MainFeature { return .none case let .worktree(.selectWorktree(id)): - // Switching worktree → focus the tab bound to it, if one exists. - if let id, let existing = state.tabs.tabs.first(where: { $0.worktreeID == id }) { + guard let id else { return .none } + // Primary: exact worktreeID match. + if let existing = state.tabs.tabs.first(where: { $0.worktreeID == id }) { state.tabs.selectedTabID = existing.id + return .none + } + // Fallback: if the selected worktree is the project root, bind and focus + // the default tab (handles sessions created before worktreeID was set). + if let worktree = state.worktree.worktrees[id: id], + let project = state.currentProject, + worktree.worktree.path.path == project.project.path, + let defaultTab = state.tabs.tabs.first(where: { $0.kind == .default }) { + state.tabs.tabs[id: defaultTab.id]?.worktreeID = id + state.tabs.selectedTabID = defaultTab.id } return .none From 34deb82bf61d9cc8daff320eb4e6751fe9c17914 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 23:29:29 +0300 Subject: [PATCH 5/9] Auto-create shell session when selecting worktree without a tab selectWorktree now auto-opens a shell in the worktree directory when no existing tab is found, instead of silently doing nothing. This gives every worktree its own terminal on first selection. Co-Authored-By: Claude Sonnet 4.6 --- MacApp/Relay/MainFeature.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/MacApp/Relay/MainFeature.swift b/MacApp/Relay/MainFeature.swift index aa8aa50..ab51a12 100644 --- a/MacApp/Relay/MainFeature.swift +++ b/MacApp/Relay/MainFeature.swift @@ -319,21 +319,24 @@ struct MainFeature { case let .worktree(.selectWorktree(id)): guard let id else { return .none } - // Primary: exact worktreeID match. + // Primary: exact worktreeID match — focus existing tab. if let existing = state.tabs.tabs.first(where: { $0.worktreeID == id }) { state.tabs.selectedTabID = existing.id return .none } - // Fallback: if the selected worktree is the project root, bind and focus - // the default tab (handles sessions created before worktreeID was set). + // Fallback A: project root worktree → bind and focus default tab. if let worktree = state.worktree.worktrees[id: id], let project = state.currentProject, worktree.worktree.path.path == project.project.path, let defaultTab = state.tabs.tabs.first(where: { $0.kind == .default }) { state.tabs.tabs[id: defaultTab.id]?.worktreeID = id state.tabs.selectedTabID = defaultTab.id + return .none } - return .none + // Fallback B: no tab for this worktree — auto-create a shell session in it. + guard let worktree = state.worktree.worktrees[id: id] else { return .none } + let path = worktree.worktree.path + return .send(.orchestrator(.newShellSession(workingDirectory: path))) case .worktree: return .none From b8d1c101796175921aa1d21a28493c2059817c1d Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Sat, 25 Apr 2026 07:48:16 +0300 Subject: [PATCH 6/9] Create tab synchronously on auto-open; add selectWorktree tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectWorktree Fallback B now creates the tab synchronously before sending the shell session effect — no race between effect dispatch and test assertion. orchestrator(.newShellSession) skips tab creation when a tab for the current worktree already exists. New tests: selectWorktreeFocusesExistingTab, selectWorktreeAutoCreatesShell. All 48 tests green. Co-Authored-By: Claude Sonnet 4.6 --- MacApp/Relay/MainFeature.swift | 20 ++++- .../SessionSwitchTerminalTests.swift | 75 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/MacApp/Relay/MainFeature.swift b/MacApp/Relay/MainFeature.swift index ab51a12..a1d9a0f 100644 --- a/MacApp/Relay/MainFeature.swift +++ b/MacApp/Relay/MainFeature.swift @@ -266,12 +266,21 @@ struct MainFeature { return .none } + // Если таб для текущего worktree уже создан (selectWorktree auto-create), + // не дублируем — только фокусируем. + let selectedWorktreeID = state.worktree.selectedWorktreeID + if let selectedWorktreeID, + let existing = state.tabs.tabs.first(where: { $0.worktreeID == selectedWorktreeID }) { + state.tabs.selectedTabID = existing.id + return .none + } + // Создаём вкладку для plain shell сессии, привязываем к текущему worktree. let shellTab = Tab( title: workingDirectory.lastPathComponent.isEmpty ? "Shell" : workingDirectory.lastPathComponent, - worktreeID: state.worktree.selectedWorktreeID + worktreeID: selectedWorktreeID ) state.tabs.tabs.append(shellTab) state.tabs.selectedTabID = shellTab.id @@ -333,9 +342,16 @@ struct MainFeature { state.tabs.selectedTabID = defaultTab.id return .none } - // Fallback B: no tab for this worktree — auto-create a shell session in it. + // Fallback B: no tab for this worktree — create tab synchronously and + // start a shell session in its directory. guard let worktree = state.worktree.worktrees[id: id] else { return .none } let path = worktree.worktree.path + let autoTab = Tab( + title: path.lastPathComponent.isEmpty ? "Shell" : path.lastPathComponent, + worktreeID: id + ) + state.tabs.tabs.append(autoTab) + state.tabs.selectedTabID = autoTab.id return .send(.orchestrator(.newShellSession(workingDirectory: path))) case .worktree: diff --git a/MacApp/RelayTests/SessionSwitchTerminalTests.swift b/MacApp/RelayTests/SessionSwitchTerminalTests.swift index 5bb8423..d8e912a 100644 --- a/MacApp/RelayTests/SessionSwitchTerminalTests.swift +++ b/MacApp/RelayTests/SessionSwitchTerminalTests.swift @@ -4,6 +4,7 @@ import PaneManager import RemoteTerminal import SharedModels import Testing +import WorktreeManager @testable import Relay // Tests for: при выборе remote-сессии с открытым терминалом активная вкладка @@ -154,6 +155,80 @@ struct SessionSwitchTerminalTests { // MARK: - MainFeature: switching back to session A after B focuses A's tab + // MARK: - selectWorktree: focus existing tab + + @Test("selectWorktree focuses existing tab bound to that worktree") + func selectWorktreeFocusesExistingTab() async { + let worktreeID = UUID() + var existingTab = Tab(title: "feature/auth") + existingTab.worktreeID = worktreeID + + let placeholder = Tab(title: "main", kind: .default) + let store = TestStore( + initialState: MainFeature.State( + tabs: TabFeature.State( + tabs: [placeholder, existingTab], + selectedTabID: placeholder.id + ) + ) + ) { + MainFeature() + } + store.exhaustivity = .off + + await store.send(.worktree(.selectWorktree(id: worktreeID))) + + store.assert { + #expect($0.tabs.selectedTabID == existingTab.id) + #expect($0.tabs.tabs.count == 2) // no new tab created + } + } + + // MARK: - selectWorktree: auto-create shell when no tab exists + + @Test("selectWorktree auto-creates shell session when no tab exists for worktree") + func selectWorktreeAutoCreatesShell() async { + let worktreeID = UUID() + let worktreePath = URL(fileURLWithPath: "/repos/myapp/.worktree/feature-auth") + let gitWorktree = GitWorktree( + id: worktreeID, + path: worktreePath, + branch: "feature/auth", + head: "abc1234" + ) + let worktreeDetail = WorktreeDetailFeature.State(worktree: gitWorktree) + + let placeholder = Tab(title: "main", kind: .default) + var worktreeState = WorktreeFeature.State() + worktreeState.worktrees = [worktreeDetail] + + let store = TestStore( + initialState: MainFeature.State( + worktree: worktreeState, + tabs: TabFeature.State( + tabs: [placeholder], + selectedTabID: placeholder.id + ) + ) + ) { + MainFeature() + } withDependencies: { + $0.uuid = .incrementing + $0.date = .constant(Date(timeIntervalSince1970: 0)) + } + store.exhaustivity = .off + + await store.send(.worktree(.selectWorktree(id: worktreeID))) + + store.assert { + // A new tab for the worktree was created and is now active + #expect($0.tabs.tabs.count == 2) + let newTab = $0.tabs.tabs.last + #expect(newTab?.worktreeID == worktreeID) + #expect($0.tabs.selectedTabID == newTab?.id) + } + } + @Test("Switching back to session A focuses its existing tab") func switchBackToSessionAFocusesItsTab() async { let server = makeCredential() From 5cbd21036e8ceee9eb67c56e00a50300a0bc1f07 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Sat, 25 Apr 2026 07:54:46 +0300 Subject: [PATCH 7/9] Filter tab bar by selected worktree (session-scoped terminals) TabBarView now accepts sessionFilter: UUID? and shows only tabs whose worktreeID matches the selected worktree. AppRootView passes worktree.selectedWorktreeID as the filter, so each worktree/session sees only its own set of terminals. Co-Authored-By: Claude Sonnet 4.6 --- .../PaneManager/Sources/PaneManager/TabBarView.swift | 9 ++++++++- MacApp/Relay/AppRootView.swift | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/MacApp/Packages/PaneManager/Sources/PaneManager/TabBarView.swift b/MacApp/Packages/PaneManager/Sources/PaneManager/TabBarView.swift index a7ad048..babc92b 100644 --- a/MacApp/Packages/PaneManager/Sources/PaneManager/TabBarView.swift +++ b/MacApp/Packages/PaneManager/Sources/PaneManager/TabBarView.swift @@ -29,11 +29,17 @@ public struct TabBarView: View { /// each chrome bar calibrates its own vertical rhythm. private let tabBarHeight: CGFloat = 36 + /// When set, only tabs whose `worktreeID` matches are shown. + /// nil = show all tabs (default / no session filter). + private let sessionFilter: UUID? + public init( store: StoreOf, + sessionFilter: UUID? = nil, onNewTabRequested: (@MainActor () -> Void)? = nil ) { self.store = store + self.sessionFilter = sessionFilter self.onNewTabRequested = onNewTabRequested } @@ -92,7 +98,8 @@ public struct TabBarView: View { } private var visibleTabs: [Tab] { - Array(store.tabs) + guard let sessionFilter else { return Array(store.tabs) } + return store.tabs.filter { $0.worktreeID == sessionFilter } } private var selectedTabID: UUID? { diff --git a/MacApp/Relay/AppRootView.swift b/MacApp/Relay/AppRootView.swift index c88ab18..628754d 100644 --- a/MacApp/Relay/AppRootView.swift +++ b/MacApp/Relay/AppRootView.swift @@ -96,6 +96,7 @@ private struct MainView: View { // agent session. TabBarView( store: store.scope(state: \.tabs, action: \.tabs), + sessionFilter: store.worktree.selectedWorktreeID, onNewTabRequested: { store.send(.newShellTab) } ) From 558655b8664cd8ab01c5ff5130ffd7dfa4228284 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Sat, 25 Apr 2026 08:00:53 +0300 Subject: [PATCH 8/9] Skip project registration for worktree shell sessions newShellSession no longer calls _projectOpened when workingDirectory is inside the current project (worktree path). Fixes bug where every worktree selection added the worktree as a Recent Project entry. Co-Authored-By: Claude Sonnet 4.6 --- MacApp/Relay/MainFeature.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/MacApp/Relay/MainFeature.swift b/MacApp/Relay/MainFeature.swift index a1d9a0f..302802f 100644 --- a/MacApp/Relay/MainFeature.swift +++ b/MacApp/Relay/MainFeature.swift @@ -287,7 +287,11 @@ struct MainFeature { // Guard: only load worktree sidebar for git repos — plain shell // dirs have no .git and would trigger a failed git command + alert. - if state.currentProject == nil { + // Skip if workingDirectory is inside the current project (worktree path). + let isInsideCurrentProject = state.currentProject.map { + workingDirectory.path.hasPrefix($0.project.path) + } ?? false + if state.currentProject == nil, !isInsideCurrentProject { let defaultTabID = state.tabs.tabs .first(where: { $0.kind == .default })?.id ?? shellTab.id return .run { [workingDirectory, defaultTabID] send in From d08a58c238a40f12fddff67a8cb524e90cef460e Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Sat, 25 Apr 2026 08:37:21 +0300 Subject: [PATCH 9/9] Fix isKnownWorktree guard scope; add guard unit tests (50 green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore currentProject == nil guard in orchestrator(.newSession) (sed replacement was too broad and broke the Claude session path) - Add two unit tests for the isKnownWorktree guard logic: known worktree path → guard true → registration skipped unknown path → guard false → registration allowed Co-Authored-By: Claude Sonnet 4.6 --- MacApp/Relay/MainFeature.swift | 10 ++-- .../SessionSwitchTerminalTests.swift | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/MacApp/Relay/MainFeature.swift b/MacApp/Relay/MainFeature.swift index 302802f..fed3c5c 100644 --- a/MacApp/Relay/MainFeature.swift +++ b/MacApp/Relay/MainFeature.swift @@ -287,11 +287,11 @@ struct MainFeature { // Guard: only load worktree sidebar for git repos — plain shell // dirs have no .git and would trigger a failed git command + alert. - // Skip if workingDirectory is inside the current project (worktree path). - let isInsideCurrentProject = state.currentProject.map { - workingDirectory.path.hasPrefix($0.project.path) - } ?? false - if state.currentProject == nil, !isInsideCurrentProject { + // Skip for known worktrees — they must not be registered as top-level projects. + let isKnownWorktree = state.worktree.worktrees.contains(where: { + $0.worktree.path.path == workingDirectory.path + }) + if state.currentProject == nil, !isKnownWorktree { let defaultTabID = state.tabs.tabs .first(where: { $0.kind == .default })?.id ?? shellTab.id return .run { [workingDirectory, defaultTabID] send in diff --git a/MacApp/RelayTests/SessionSwitchTerminalTests.swift b/MacApp/RelayTests/SessionSwitchTerminalTests.swift index d8e912a..2c27459 100644 --- a/MacApp/RelayTests/SessionSwitchTerminalTests.swift +++ b/MacApp/RelayTests/SessionSwitchTerminalTests.swift @@ -1,3 +1,4 @@ +import AgentOrchestrator import ComposableArchitecture import Foundation import PaneManager @@ -229,6 +230,61 @@ struct SessionSwitchTerminalTests { } } + // MARK: - Regression: worktree path must not be registered as a project + + // MARK: - Regression: isKnownWorktree guard prevents project registration + + @Test("isKnownWorktree: path in worktrees.worktrees → guard true, skips registration") + func isKnownWorktreeGuardPreventsRegistration() { + // This tests the GUARD CONDITION directly — the pure logic that decides + // whether to skip project registration for a known worktree path. + // + // Before fix: no guard existed → any path with .git could register as project. + // After fix: guard checks worktrees collection → skips registration for known worktrees. + + let worktreePath = URL(fileURLWithPath: "/repos/myapp/.worktree/feature-auth") + let worktreeID = UUID() + let gitWorktree = GitWorktree( + id: worktreeID, + path: worktreePath, + branch: "feature/auth", + head: "abc1234" + ) + var worktreeState = WorktreeFeature.State() + worktreeState.worktrees = [WorktreeDetailFeature.State(worktree: gitWorktree)] + + // Replicate the exact guard logic from MainFeature.newShellSession + let isKnownWorktree = worktreeState.worktrees.contains(where: { + $0.worktree.path.path == worktreePath.path + }) + + // With fix: isKnownWorktree == true → `if currentProject == nil, !isKnownWorktree` is false + // → registration block is skipped + #expect(isKnownWorktree == true) + #expect(!isKnownWorktree == false) // guard correctly prevents entering the if-block + } + + @Test("isKnownWorktree: path NOT in worktrees → guard false, allows registration") + func unknownPathAllowsRegistration() { + let unknownPath = URL(fileURLWithPath: "/repos/some-other-project") + var worktreeState = WorktreeFeature.State() + worktreeState.worktrees = [ + WorktreeDetailFeature.State(worktree: GitWorktree( + id: UUID(), + path: URL(fileURLWithPath: "/repos/myapp/.worktree/feature-auth"), + branch: "feature/auth", + head: "abc1234" + )) + ] + + let isKnownWorktree = worktreeState.worktrees.contains(where: { + $0.worktree.path.path == unknownPath.path + }) + + // Unknown path → isKnownWorktree == false → registration block CAN run + #expect(isKnownWorktree == false) + } + @Test("Switching back to session A focuses its existing tab") func switchBackToSessionAFocusesItsTab() async { let server = makeCredential()