Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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<TabFeature>,
sessionFilter: UUID? = nil,
onNewTabRequested: (@MainActor () -> Void)? = nil
) {
self.store = store
self.sessionFilter = sessionFilter
self.onNewTabRequested = onNewTabRequested
}

Expand Down Expand Up @@ -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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -29,13 +33,15 @@ public struct Tab: Equatable, Identifiable, Sendable {
terminalSessionID: UUID? = nil,
agentSessionID: UUID? = nil,
worktreeID: UUID? = nil,
remoteSessionID: String? = nil,
kind: SessionKind = .userCreated
) {
self.id = id
self.title = title
self.terminalSessionID = terminalSessionID
self.agentSessionID = agentSessionID
self.worktreeID = worktreeID
self.remoteSessionID = remoteSessionID
self.kind = kind
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ public struct TerminalFeature {

/// Внутренние — результат создания сессии.
case _sessionCreated(UUID)
/// Внутренние — сессия не смогла подключиться (gRPC / PTY ошибка).
case _connectionFailed(String)
}

// MARK: - Dependencies
Expand Down Expand Up @@ -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)

Expand All @@ -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)
}
Expand Down
14 changes: 8 additions & 6 deletions MacApp/Relay/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<Action> {
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)))
}
}
}
1 change: 1 addition & 0 deletions MacApp/Relay/AppRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
)

Expand Down
117 changes: 91 additions & 26 deletions MacApp/Relay/MainFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -169,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)))
Expand All @@ -184,7 +190,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):
Expand All @@ -206,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
Expand All @@ -233,26 +252,46 @@ 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 сессии
// Если таб для текущего 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
: workingDirectory.lastPathComponent,
worktreeID: selectedWorktreeID
)
state.tabs.tabs.append(shellTab)
state.tabs.selectedTabID = shellTab.id

// 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 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
Expand All @@ -269,11 +308,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))):
Expand All @@ -293,6 +330,34 @@ struct MainFeature {
case .orchestrator:
return .none

case let .worktree(.selectWorktree(id)):
guard let id else { return .none }
// 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 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
}
// 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:
return .none

Expand Down
Loading
Loading