diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index f63e0b626..f3de89c45 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -397,6 +397,7 @@ enum LaneIcon: String, Codable, Equatable { struct LaneSummary: Codable, Identifiable, Equatable { var id: String + var projectId: String? = nil var name: String var description: String? var laneType: String diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 75c618247..784f7d18f 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -47,6 +47,7 @@ final class DatabaseService { private struct LaneRow { let id: String + let projectId: String? let name: String let description: String? let laneType: String @@ -469,7 +470,8 @@ final class DatabaseService { ps.ahead, ps.behind, ps.remote_behind, - ps.rebase_in_progress + ps.rebase_in_progress, + l.project_id from lanes l left join lane_state_snapshots s on s.lane_id = l.id left join lane_state_snapshots ps on ps.lane_id = l.parent_lane_id @@ -486,6 +488,7 @@ final class DatabaseService { }) { statement in LaneRow( id: stringValue(statement, index: 0) ?? "", + projectId: stringValue(statement, index: 26), name: stringValue(statement, index: 1) ?? "", description: stringValue(statement, index: 2), laneType: stringValue(statement, index: 3) ?? "worktree", @@ -545,6 +548,7 @@ final class DatabaseService { var visited = Set() return LaneSummary( id: row.id, + projectId: row.projectId, name: row.name, description: row.description, laneType: row.laneType, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 517549f78..aa924f8da 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1418,6 +1418,28 @@ enum TerminalStreamEvent { case exit(code: Int?) } +struct WorkStartShellSessionRequest: Equatable { + var laneId: String + var provider = "shell" + var title = "Shell" + var cols = 48 + var rows = 24 + var targetProjectId: String? + var targetProjectRootPath: String? +} + +func workStartShellSessionRequest( + laneId: String, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil +) -> WorkStartShellSessionRequest { + WorkStartShellSessionRequest( + laneId: laneId, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) +} + @MainActor final class SyncService: ObservableObject { @Published private(set) var connectionState: RemoteConnectionState = .disconnected @@ -2789,6 +2811,7 @@ final class SyncService: ObservableObject { let hostId: String? let projectId: String? let projectRootPath: String? + let fallbackToActiveProjectScope: Bool? } init(database: DatabaseService = DatabaseService()) { @@ -5469,7 +5492,8 @@ final class SyncService: ObservableObject { cols: Int? = nil, rows: Int? = nil, targetProjectId: String? = nil, - targetProjectRootPath: String? = nil + targetProjectRootPath: String? = nil, + fallbackToActiveProjectScope: Bool = true ) async throws -> StartCliSessionResult { var args: [String: Any] = [ "laneId": laneId, @@ -5504,10 +5528,33 @@ final class SyncService: ObservableObject { args: args, targetProjectId: targetProjectId, targetProjectRootPath: targetProjectRootPath, + fallbackToActiveProjectScope: fallbackToActiveProjectScope, as: StartCliSessionResult.self ) } + func startShellSession( + laneId: String, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil + ) async throws -> StartCliSessionResult { + let request = workStartShellSessionRequest( + laneId: laneId, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) + return try await startCliSession( + laneId: request.laneId, + provider: request.provider, + title: request.title, + cols: request.cols, + rows: request.rows, + targetProjectId: request.targetProjectId, + targetProjectRootPath: request.targetProjectRootPath, + fallbackToActiveProjectScope: false + ) + } + func stopWorkRuntime(sessionId: String) async throws { // Scope to the session's project so a stop issued from a cross-project // quick look reaches the right runtime. @@ -8812,14 +8859,15 @@ final class SyncService: ObservableObject { terminalBufferRevision += 1 } - func pendingOperationsForTesting() -> [(id: String, kind: String, action: String, projectId: String?, projectRootPath: String?)] { + func pendingOperationsForTesting() -> [(id: String, kind: String, action: String, projectId: String?, projectRootPath: String?, fallbackToActiveProjectScope: Bool?)] { loadPendingOperations().map { operation in ( id: operation.id, kind: operation.kind, action: operation.action, projectId: operation.projectId, - projectRootPath: operation.projectRootPath + projectRootPath: operation.projectRootPath, + fallbackToActiveProjectScope: operation.fallbackToActiveProjectScope ) } } @@ -9865,6 +9913,7 @@ final class SyncService: ObservableObject { timeoutNanoseconds: UInt64? = nil, targetProjectId: String? = nil, targetProjectRootPath: String? = nil, + fallbackToActiveProjectScope: Bool = true, as type: T.Type ) async throws -> T { let response = try await sendCommand( @@ -9873,7 +9922,8 @@ final class SyncService: ObservableObject { disconnectOnTimeout: disconnectOnTimeout, timeoutNanoseconds: timeoutNanoseconds, targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath + targetProjectRootPath: targetProjectRootPath, + fallbackToActiveProjectScope: fallbackToActiveProjectScope ) if let payload = response as? [String: Any], payload["queued"] as? Bool == true { throw QueuedRemoteCommandError(action: action) @@ -10027,7 +10077,8 @@ final class SyncService: ObservableObject { args: [String: Any], id: String? = nil, targetProjectId: String? = nil, - targetProjectRootPath: String? = nil + targetProjectRootPath: String? = nil, + fallbackToActiveProjectScope: Bool = true ) throws { guard JSONSerialization.isValidJSONObject(args) else { throw NSError(domain: "ADE", code: 11, userInfo: [NSLocalizedDescriptionKey: "Invalid queued operation payload."]) @@ -10041,8 +10092,9 @@ final class SyncService: ObservableObject { payload: payload, queuedAt: syncDateFormatter.string(from: Date()), hostId: activeHostStorageKey(), - projectId: targetProjectId ?? activeProjectId, - projectRootPath: targetProjectRootPath ?? activeProjectRootPath + projectId: fallbackToActiveProjectScope ? (targetProjectId ?? activeProjectId) : targetProjectId, + projectRootPath: fallbackToActiveProjectScope ? (targetProjectRootPath ?? activeProjectRootPath) : targetProjectRootPath, + fallbackToActiveProjectScope: fallbackToActiveProjectScope )) savePendingOperations(queued) if canSendLiveRequests() { @@ -10149,7 +10201,8 @@ final class SyncService: ObservableObject { args: args, commandId: operation.id, targetProjectId: operation.projectId, - targetProjectRootPath: operation.projectRootPath + targetProjectRootPath: operation.projectRootPath, + fallbackToActiveProjectScope: operation.fallbackToActiveProjectScope ?? true ) case "file": guard queueableFileActions.contains(operation.action) else { @@ -10205,8 +10258,12 @@ final class SyncService: ObservableObject { timeoutMessage: String = SyncRequestTimeout.message, timeoutNanoseconds: UInt64? = nil, targetProjectId: String? = nil, - targetProjectRootPath: String? = nil + targetProjectRootPath: String? = nil, + fallbackToActiveProjectScope: Bool = true ) async throws -> Any { + if !fallbackToActiveProjectScope && syncNormalizedCommandScopeValue(targetProjectId) == nil { + throw NSError(domain: "ADE", code: 26, userInfo: [NSLocalizedDescriptionKey: "This action needs the lane's project scope. Refresh lanes and try again."]) + } guard canSendLiveRequests() else { throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "The machine is offline."]) } @@ -10215,9 +10272,10 @@ final class SyncService: ObservableObject { // `targetProjectId` lets a command create-in-place in a NON-active project // (mobile hub composer): the host routes the command to that project's scope // via the command-payload projectId without switching the phone's active - // sync project. Defaults to the active project for every existing caller. - let resolvedProjectId = targetProjectId ?? self.activeProjectId - let resolvedProjectRootPath = targetProjectRootPath ?? self.activeProjectRootPath + // sync project. Most callers default to the active project; shell launches + // opt out when the selected lane does not carry trustworthy project scope. + let resolvedProjectId = fallbackToActiveProjectScope ? (targetProjectId ?? self.activeProjectId) : targetProjectId + let resolvedProjectRootPath = fallbackToActiveProjectScope ? (targetProjectRootPath ?? self.activeProjectRootPath) : targetProjectRootPath let raw = try await awaitResponse( requestId: requestId, disconnectOnTimeout: disconnectOnTimeout, @@ -10246,8 +10304,12 @@ final class SyncService: ObservableObject { timeoutMessage: String = SyncRequestTimeout.message, timeoutNanoseconds: UInt64? = nil, targetProjectId: String? = nil, - targetProjectRootPath: String? = nil + targetProjectRootPath: String? = nil, + fallbackToActiveProjectScope: Bool = true ) async throws -> Any { + if !fallbackToActiveProjectScope && syncNormalizedCommandScopeValue(targetProjectId) == nil { + throw NSError(domain: "ADE", code: 26, userInfo: [NSLocalizedDescriptionKey: "This action needs the lane's project scope. Refresh lanes and try again."]) + } let commandId = makeRequestId() if canSendLiveRequests() { do { @@ -10259,7 +10321,8 @@ final class SyncService: ObservableObject { timeoutMessage: timeoutMessage, timeoutNanoseconds: timeoutNanoseconds, targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath + targetProjectRootPath: targetProjectRootPath, + fallbackToActiveProjectScope: fallbackToActiveProjectScope ) } catch { let stillLive = canSendLiveRequests() @@ -10274,7 +10337,8 @@ final class SyncService: ObservableObject { args: args, id: commandId, targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath + targetProjectRootPath: targetProjectRootPath, + fallbackToActiveProjectScope: fallbackToActiveProjectScope ) if stillLive, isSyncRequestTimeoutError(error) { verifyTransportAliveAfterRequestTimeout(error as NSError) @@ -10295,7 +10359,8 @@ final class SyncService: ObservableObject { action: action, args: args, targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath + targetProjectRootPath: targetProjectRootPath, + fallbackToActiveProjectScope: fallbackToActiveProjectScope ) return ["queued": true] } diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 1ad353c88..b8c49bb63 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -39,6 +39,56 @@ enum WorkAutoLaneNamingOutcome: Equatable { case renameFailed(String) } +struct WorkProjectCommandScope: Equatable { + let projectId: String? + let projectRootPath: String? +} + +private func workNonEmptyScopeValue(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed +} + +private func workShellProjectRootCandidate(from path: String?) -> String? { + guard let normalized = syncNormalizedProjectRootScope(path) else { return nil } + if let range = normalized.range(of: "/.ade/worktrees/") { + return String(normalized[.. [String] { + var seen = Set() + return roots.compactMap { root in + guard let root, !seen.contains(root) else { return nil } + seen.insert(root) + return root + } +} + +func workShellProjectScope( + for lane: LaneSummary, + projects: [MobileProjectSummary] +) -> WorkProjectCommandScope { + let laneProjectId = workNonEmptyScopeValue(lane.projectId) + let projectById = laneProjectId.flatMap { id in projects.first { $0.id == id } } + let expectedRoots = workUniqueRoots([ + workShellProjectRootCandidate(from: lane.attachedRootPath), + workShellProjectRootCandidate(from: lane.worktreePath), + ]) + + let projectByLanePath = projects.first { project in + guard let root = syncNormalizedProjectRootScope(project.rootPath) else { return false } + return expectedRoots.contains(root) + } + + let project = laneProjectId == nil ? projectByLanePath : projectById + let projectId = laneProjectId ?? project?.id + let rootPath = syncNormalizedProjectRootScope(project?.rootPath) + + return WorkProjectCommandScope(projectId: projectId, projectRootPath: rootPath) +} + @discardableResult @MainActor func workRunAutoLaneAiRename( @@ -486,6 +536,7 @@ struct WorkNewChatScreen: View { /// construction (the screen is pushed for the active project) so it is stable /// while shown. let activeProjectId: String? + let activeProjectRootPath: String? let onStarted: @MainActor (AgentChatSessionSummary, String) async -> Void let onCliStarted: @MainActor (TerminalSessionSummary) async -> Void let onChatImported: @MainActor (String) async -> Void @@ -506,6 +557,8 @@ struct WorkNewChatScreen: View { /// advertised fast model and wrongly hide the toggle. @State private var selectedModelOption: WorkModelOption? @State private var sessionMode: WorkNewSessionMode = .chat + @State private var shellLaunchBusy: Bool = false + @State private var queuedShellLaneIds = Set() /// Status banner shown above the composer while an auto-created lane is being /// minted before the chat/CLI session starts. @State private var autoCreateStatus: String? @@ -514,6 +567,7 @@ struct WorkNewChatScreen: View { lanes: [LaneSummary], preferredLaneId: String?, activeProjectId: String?, + activeProjectRootPath: String?, onStarted: @escaping @MainActor (AgentChatSessionSummary, String) async -> Void, onCliStarted: @escaping @MainActor (TerminalSessionSummary) async -> Void, onChatImported: @escaping @MainActor (String) async -> Void = { _ in }, @@ -522,6 +576,7 @@ struct WorkNewChatScreen: View { self.lanes = lanes self.preferredLaneId = preferredLaneId self.activeProjectId = activeProjectId + self.activeProjectRootPath = activeProjectRootPath self.onStarted = onStarted self.onCliStarted = onCliStarted self.onChatImported = onChatImported @@ -651,7 +706,7 @@ struct WorkNewChatScreen: View { .padding(.bottom, 6) } - importSessionChip + sessionActionChips composerBar } @@ -768,10 +823,21 @@ struct WorkNewChatScreen: View { // Sits just above the composer, like the context chips in a chat. @ViewBuilder - private var importSessionChip: some View { + private var sessionActionChips: some View { if let lane = selectedConcreteLane { - HStack { + let chipsDisabled = busy || shellLaunchBusy + let shellQueued = queuedShellLaneIds.contains(lane.id) + let shellDisabled = chipsDisabled || shellQueued + HStack(spacing: 8) { Spacer(minLength: 0) + Button { + Task { await launchShell(in: lane) } + } label: { + shellSessionAffordance(isBusy: shellLaunchBusy, isQueued: shellQueued, disabled: shellDisabled) + } + .buttonStyle(.plain) + .disabled(shellDisabled) + NavigationLink { WorkImportSessionScreen( lane: lane, @@ -780,9 +846,10 @@ struct WorkNewChatScreen: View { ) .environmentObject(syncService) } label: { - importSessionAffordance(disabled: false) + importSessionAffordance(disabled: chipsDisabled) } .buttonStyle(.plain) + .disabled(chipsDisabled) Spacer(minLength: 0) } .padding(.horizontal, 20) @@ -790,6 +857,33 @@ struct WorkNewChatScreen: View { } } + private func shellSessionAffordance(isBusy: Bool, isQueued: Bool, disabled: Bool) -> some View { + HStack(spacing: 6) { + if isBusy { + ProgressView() + .controlSize(.mini) + .tint(ADEColor.accent) + } else if isQueued { + Image(systemName: "clock.badge.checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + } else { + Image(systemName: "terminal") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(disabled ? ADEColor.textMuted : ADEColor.accent) + } + Text(isBusy ? "Starting shell" : (isQueued ? "Shell queued" : "Shell")) + .font(.subheadline.weight(.medium)) + .foregroundStyle(disabled ? ADEColor.textMuted : ADEColor.textPrimary) + } + .padding(.horizontal, 14) + .frame(height: 34) + .background(ADEColor.surfaceBackground.opacity(disabled ? 0.36 : 0.7), in: Capsule(style: .continuous)) + .overlay { Capsule(style: .continuous).stroke(ADEColor.glassBorder.opacity(disabled ? 0.5 : 1), lineWidth: 0.6) } + .opacity(disabled && !isBusy ? 0.5 : 1) + .accessibilityLabel(isBusy ? "Starting shell" : (isQueued ? "Shell queued" : "Open shell")) + } + private func importSessionAffordance(disabled: Bool) -> some View { HStack(spacing: 6) { Image(systemName: "square.and.arrow.down") @@ -814,7 +908,7 @@ struct WorkNewChatScreen: View { modelId: modelId, modelName: prettyNewChatModelName(modelId), busy: busy, - canStart: !busy && (isAutoCreateLane || !selectedLaneId.isEmpty) && !modelId.isEmpty, + canStart: !busy && !shellLaunchBusy && (isAutoCreateLane || !selectedLaneId.isEmpty) && !modelId.isEmpty, runtimeMode: $runtimeMode, reasoningEffort: $reasoningEffort, fastModeSupported: fastModeSupported, @@ -850,10 +944,66 @@ struct WorkNewChatScreen: View { return trimmed } + @MainActor + private func launchShell(in lane: LaneSummary) async { + guard !busy && !shellLaunchBusy else { return } + shellLaunchBusy = true + errorMessage = nil + defer { shellLaunchBusy = false } + + do { + let scope = workShellProjectScope( + for: lane, + projects: syncService.projects + ) + let result = try await syncService.startShellSession( + laneId: lane.id, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.projectRootPath + ) + if let session = result.session { + await onCliStarted(session) + } else { + await onCliStarted(TerminalSessionSummary( + id: result.sessionId, + laneId: lane.id, + laneName: lane.name, + ptyId: result.ptyId, + tracked: true, + pinned: false, + manuallyNamed: nil, + goal: nil, + toolType: "shell", + title: "Shell", + status: "running", + startedAt: workDateFormatter.string(from: Date()), + endedAt: nil, + exitCode: nil, + transcriptPath: "", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: nil, + summary: nil, + runtimeState: "running", + resumeCommand: nil, + resumeMetadata: nil, + chatIdleSinceAt: nil + )) + } + } catch is QueuedRemoteCommandError { + ADEHaptics.medium() + queuedShellLaneIds.insert(lane.id) + errorMessage = nil + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + } + } + @MainActor private func submit(openingMessage: String) async -> Bool { let opener = openingMessage.trimmingCharacters(in: .whitespacesAndNewlines) - guard !busy && (isAutoCreateLane || !selectedLaneId.isEmpty) else { return false } + guard !busy && !shellLaunchBusy && (isAutoCreateLane || !selectedLaneId.isEmpty) else { return false } guard !opener.isEmpty && !modelId.isEmpty else { return false } // Anchor the "last time you sent a message" choice — covers the case where // the user sent with the restored/default selection without changing it. @@ -1121,6 +1271,7 @@ private func workCliToolType(provider: String) -> String { case "cursor": return "cursor-cli" case "opencode": return "opencode" case "droid": return "droid" + case "shell": return "shell" default: return "opencode" } } diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 605ef5ebc..f23e1b20e 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -500,6 +500,7 @@ private enum WorkPreviewData { lanes: [WorkPreviewData.lane], preferredLaneId: WorkPreviewData.lane.id, activeProjectId: nil, + activeProjectRootPath: nil, onStarted: { _, _ in }, onCliStarted: { _ in }, onRefreshLanes: {} diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index cab8e260f..7d3260f2e 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -660,6 +660,7 @@ struct WorkRootScreen: View { lanes: workOrderedLanes.isEmpty ? lanes : workOrderedLanes, preferredLaneId: route.preferredLaneId, activeProjectId: syncService.activeProjectId, + activeProjectRootPath: syncService.activeProjectRootPath, onStarted: { summary, opener in let sessionId = summary.sessionId let trimmed = opener.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 3530462a5..198edc1e4 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -502,6 +502,242 @@ final class ADETests: XCTestCase { XCTAssertEqual(workCliPermissionMode(provider: "claude", runtimeMode: "auto"), "auto") } + func testWorkStartShellSessionRequestUsesShellDefaultsAndScope() { + let request = workStartShellSessionRequest( + laneId: "lane-work", + targetProjectId: "project-1", + targetProjectRootPath: "/tmp/project-one" + ) + XCTAssertEqual(request.laneId, "lane-work") + XCTAssertEqual(request.provider, "shell") + XCTAssertEqual(request.title, "Shell") + XCTAssertEqual(request.cols, 48) + XCTAssertEqual(request.rows, 24) + XCTAssertEqual(request.targetProjectId, "project-1") + XCTAssertEqual(request.targetProjectRootPath, "/tmp/project-one") + } + + func testWorkShellProjectScopePrefersSelectedLaneProject() { + var lane = makeLaneSummary(id: "lane-work", name: "Work", laneType: "worktree", branchRef: "ade/work") + lane.projectId = "project-lane" + lane.worktreePath = "/tmp/project-lane/.ade/worktrees/lane-work" + + let scope = workShellProjectScope( + for: lane, + projects: [ + MobileProjectSummary( + id: "project-active", + displayName: "Active", + rootPath: "/tmp/project-active", + laneCount: 1, + isAvailable: true, + isCached: true + ), + MobileProjectSummary( + id: "project-lane", + displayName: "Lane", + rootPath: "/tmp/project-lane", + laneCount: 1, + isAvailable: true, + isCached: true + ), + ] + ) + + XCTAssertEqual(scope.projectId, "project-lane") + XCTAssertEqual(scope.projectRootPath, "/tmp/project-lane") + } + + func testWorkShellProjectScopeDoesNotMixForeignLaneIdWithActiveRoot() { + var lane = makeLaneSummary(id: "lane-foreign", name: "Foreign", laneType: "worktree", branchRef: "ade/foreign") + lane.projectId = "project-foreign" + + let scope = workShellProjectScope( + for: lane, + projects: [] + ) + + XCTAssertEqual(scope.projectId, "project-foreign") + XCTAssertNil(scope.projectRootPath) + } + + func testWorkShellProjectScopeKeepsKnownLaneProjectWhenCatalogIsStale() { + var lane = makeLaneSummary(id: "lane-mobile", name: "Mobile", laneType: "worktree", branchRef: "ade/mobile") + lane.projectId = "project-mobile" + lane.worktreePath = "/repo/mobile/.ade/worktrees/lane-mobile" + + let scope = workShellProjectScope( + for: lane, + projects: [ + MobileProjectSummary( + id: "project-parent", + displayName: "Parent", + rootPath: "/repo", + laneCount: 1, + isAvailable: true, + isCached: true + ), + ] + ) + + XCTAssertEqual(scope.projectId, "project-mobile") + XCTAssertNil(scope.projectRootPath) + } + + func testWorkShellProjectScopeDoesNotFallbackToActiveProjectWhenLaneScopeIsMissing() { + let lane = makeLaneSummary(id: "lane-missing", name: "Missing", laneType: "worktree", branchRef: "ade/missing") + + let scope = workShellProjectScope( + for: lane, + projects: [ + MobileProjectSummary( + id: "project-active", + displayName: "Active", + rootPath: "/tmp/project-active", + laneCount: 1, + isAvailable: true, + isCached: true + ), + ] + ) + + XCTAssertNil(scope.projectId) + XCTAssertNil(scope.projectRootPath) + } + + func testWorkShellProjectScopePrefersMostSpecificPathMatch() { + var lane = makeLaneSummary(id: "lane-mobile", name: "Mobile", laneType: "worktree", branchRef: "ade/mobile") + lane.worktreePath = "/repo/mobile/.ade/worktrees/lane-mobile" + + let scope = workShellProjectScope( + for: lane, + projects: [ + MobileProjectSummary( + id: "project-parent", + displayName: "Parent", + rootPath: "/repo", + laneCount: 1, + isAvailable: true, + isCached: true + ), + MobileProjectSummary( + id: "project-mobile", + displayName: "Mobile", + rootPath: "/repo/mobile", + laneCount: 1, + isAvailable: true, + isCached: true + ), + ] + ) + + XCTAssertEqual(scope.projectId, "project-mobile") + XCTAssertEqual(scope.projectRootPath, "/repo/mobile") + } + + func testWorkShellProjectScopeDoesNotUseParentProjectForNestedLanePath() { + var lane = makeLaneSummary(id: "lane-mobile", name: "Mobile", laneType: "worktree", branchRef: "ade/mobile") + lane.worktreePath = "/repo/mobile/.ade/worktrees/lane-mobile" + + let scope = workShellProjectScope( + for: lane, + projects: [ + MobileProjectSummary( + id: "project-parent", + displayName: "Parent", + rootPath: "/repo", + laneCount: 1, + isAvailable: true, + isCached: true + ), + ] + ) + + XCTAssertNil(scope.projectId) + XCTAssertNil(scope.projectRootPath) + } + + @MainActor + func testQueuedShellStartUsesLaneProjectScopeWithoutActiveFallback() async throws { + let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" + let pendingOperationsKey = "ade.sync.pendingOperations" + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + UserDefaults.standard.removeObject(forKey: pendingOperationsKey) + defer { + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + UserDefaults.standard.removeObject(forKey: pendingOperationsKey) + } + + let descriptors = [ + SyncRemoteCommandDescriptor( + action: "work.startCliSession", + policy: SyncRemoteCommandPolicy(viewerAllowed: true, requiresApproval: nil, localOnly: nil, queueable: true) + ), + ] + UserDefaults.standard.set(try JSONEncoder().encode(descriptors), forKey: remoteCommandDescriptorsKey) + + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { database.close() } + let service = SyncService(database: database) + service.setActiveProjectForTesting(projectId: "project-active", rootPath: "/tmp/project-active") + service.disconnect() + + do { + _ = try await service.startShellSession( + laneId: "lane-scoped", + targetProjectId: "project-lane", + targetProjectRootPath: "/tmp/project-lane" + ) + XCTFail("Expected queued shell start to throw after persisting the operation.") + } catch is QueuedRemoteCommandError { + // Expected: the shell command was queued for replay. + } + + let queued = service.pendingOperationsForTesting() + XCTAssertEqual(queued.count, 1) + XCTAssertEqual(queued.first?.kind, "command") + XCTAssertEqual(queued.first?.action, "work.startCliSession") + XCTAssertEqual(queued.first?.projectId, "project-lane") + XCTAssertEqual(queued.first?.projectRootPath, "/tmp/project-lane") + XCTAssertEqual(queued.first?.fallbackToActiveProjectScope, false) + } + + @MainActor + func testShellStartWithoutLaneProjectScopeDoesNotQueueAgainstActiveProject() async throws { + let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" + let pendingOperationsKey = "ade.sync.pendingOperations" + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + UserDefaults.standard.removeObject(forKey: pendingOperationsKey) + defer { + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + UserDefaults.standard.removeObject(forKey: pendingOperationsKey) + } + + let descriptors = [ + SyncRemoteCommandDescriptor( + action: "work.startCliSession", + policy: SyncRemoteCommandPolicy(viewerAllowed: true, requiresApproval: nil, localOnly: nil, queueable: true) + ), + ] + UserDefaults.standard.set(try JSONEncoder().encode(descriptors), forKey: remoteCommandDescriptorsKey) + + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { database.close() } + let service = SyncService(database: database) + service.setActiveProjectForTesting(projectId: "project-active", rootPath: "/tmp/project-active") + service.disconnect() + + do { + _ = try await service.startShellSession(laneId: "lane-missing") + XCTFail("Expected missing shell project scope to fail before queueing.") + } catch { + XCTAssertTrue(error.localizedDescription.contains("project scope")) + } + + XCTAssertTrue(service.pendingOperationsForTesting().isEmpty) + XCTAssertEqual(service.pendingOperationCount, 0) + } + func testMobileRuntimeModeOptionsMirrorDesktopAndTuiProviders() { XCTAssertEqual(workRuntimeModeOptions(provider: "claude").map(\.id), ["default", "auto", "edit", "plan", "full-auto"]) XCTAssertEqual(workRuntimeModeOptions(provider: "codex").map(\.id), ["default", "edit", "plan", "full-auto", "config-toml"])