From 5dc4251b0bd55ad6c7bbcdfe0c5ddc051df9a451 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:46:49 -0400 Subject: [PATCH 1/8] Add mobile shell launcher --- apps/ios/ADE/Services/SyncService.swift | 29 ++++++ .../ADE/Views/Work/WorkNewChatScreen.swift | 94 +++++++++++++++++-- apps/ios/ADETests/ADETests.swift | 9 ++ 3 files changed, 126 insertions(+), 6 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 517549f78..a9797641b 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1418,6 +1418,18 @@ enum TerminalStreamEvent { case exit(code: Int?) } +struct WorkStartShellSessionRequest: Equatable { + var laneId: String + var provider = "shell" + var title = "Shell" + var cols = 48 + var rows = 24 +} + +func workStartShellSessionRequest(laneId: String) -> WorkStartShellSessionRequest { + WorkStartShellSessionRequest(laneId: laneId) +} + @MainActor final class SyncService: ObservableObject { @Published private(set) var connectionState: RemoteConnectionState = .disconnected @@ -5508,6 +5520,23 @@ final class SyncService: ObservableObject { ) } + func startShellSession( + laneId: String, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil + ) async throws -> StartCliSessionResult { + let request = workStartShellSessionRequest(laneId: laneId) + return try await startCliSession( + laneId: request.laneId, + provider: request.provider, + title: request.title, + cols: request.cols, + rows: request.rows, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) + } + 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. diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 1ad353c88..0c2ef4472 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -506,6 +506,7 @@ 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 /// 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? @@ -651,7 +652,7 @@ struct WorkNewChatScreen: View { .padding(.bottom, 6) } - importSessionChip + sessionActionChips composerBar } @@ -768,10 +769,19 @@ 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 + HStack(spacing: 8) { Spacer(minLength: 0) + Button { + Task { await launchShell(in: lane) } + } label: { + shellSessionAffordance(isBusy: shellLaunchBusy, disabled: chipsDisabled) + } + .buttonStyle(.plain) + .disabled(chipsDisabled) + NavigationLink { WorkImportSessionScreen( lane: lane, @@ -780,9 +790,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 +801,29 @@ struct WorkNewChatScreen: View { } } + private func shellSessionAffordance(isBusy: Bool, disabled: Bool) -> some View { + HStack(spacing: 6) { + if isBusy { + ProgressView() + .controlSize(.mini) + .tint(ADEColor.accent) + } else { + Image(systemName: "terminal") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(disabled ? ADEColor.textMuted : ADEColor.accent) + } + Text(isBusy ? "Starting shell" : "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" : "Open shell") + } + private func importSessionAffordance(disabled: Bool) -> some View { HStack(spacing: 6) { Image(systemName: "square.and.arrow.down") @@ -814,7 +848,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 +884,57 @@ 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 result = try await syncService.startShellSession(laneId: lane.id) + 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 let queuedError as QueuedRemoteCommandError { + ADEHaptics.medium() + errorMessage = queuedError.errorDescription + } 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 +1202,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/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 3530462a5..0167a352f 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -502,6 +502,15 @@ final class ADETests: XCTestCase { XCTAssertEqual(workCliPermissionMode(provider: "claude", runtimeMode: "auto"), "auto") } + func testWorkStartShellSessionRequestUsesShellDefaults() { + let request = workStartShellSessionRequest(laneId: "lane-work") + XCTAssertEqual(request.laneId, "lane-work") + XCTAssertEqual(request.provider, "shell") + XCTAssertEqual(request.title, "Shell") + XCTAssertEqual(request.cols, 48) + XCTAssertEqual(request.rows, 24) + } + 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"]) From 04607204c149023016fd5b420645fe9a9d4519a1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:04:10 -0400 Subject: [PATCH 2/8] ship: iteration 1 - address shell scope review --- apps/ios/ADE/Services/SyncService.swift | 24 +++++++++++++++---- .../ADE/Views/Work/WorkNewChatScreen.swift | 9 ++++++- apps/ios/ADE/Views/Work/WorkPreviews.swift | 1 + apps/ios/ADE/Views/Work/WorkRootScreen.swift | 1 + apps/ios/ADETests/ADETests.swift | 10 ++++++-- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index a9797641b..23ace2568 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1424,10 +1424,20 @@ struct WorkStartShellSessionRequest: Equatable { var title = "Shell" var cols = 48 var rows = 24 + var targetProjectId: String? + var targetProjectRootPath: String? } -func workStartShellSessionRequest(laneId: String) -> WorkStartShellSessionRequest { - WorkStartShellSessionRequest(laneId: laneId) +func workStartShellSessionRequest( + laneId: String, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil +) -> WorkStartShellSessionRequest { + WorkStartShellSessionRequest( + laneId: laneId, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) } @MainActor @@ -5525,15 +5535,19 @@ final class SyncService: ObservableObject { targetProjectId: String? = nil, targetProjectRootPath: String? = nil ) async throws -> StartCliSessionResult { - let request = workStartShellSessionRequest(laneId: laneId) + 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: targetProjectId, - targetProjectRootPath: targetProjectRootPath + targetProjectId: request.targetProjectId, + targetProjectRootPath: request.targetProjectRootPath ) } diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 0c2ef4472..ad3ce23b0 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -486,6 +486,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 @@ -515,6 +516,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 }, @@ -523,6 +525,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 @@ -892,7 +895,11 @@ struct WorkNewChatScreen: View { defer { shellLaunchBusy = false } do { - let result = try await syncService.startShellSession(laneId: lane.id) + let result = try await syncService.startShellSession( + laneId: lane.id, + targetProjectId: activeProjectId, + targetProjectRootPath: activeProjectRootPath + ) if let session = result.session { await onCliStarted(session) } else { 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 0167a352f..80873b280 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -502,13 +502,19 @@ final class ADETests: XCTestCase { XCTAssertEqual(workCliPermissionMode(provider: "claude", runtimeMode: "auto"), "auto") } - func testWorkStartShellSessionRequestUsesShellDefaults() { - let request = workStartShellSessionRequest(laneId: "lane-work") + 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 testMobileRuntimeModeOptionsMirrorDesktopAndTuiProviders() { From c12c170029f03f297ae26f05d3b9577898f32f52 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:22:40 -0400 Subject: [PATCH 3/8] ship: iteration 2 - derive shell scope from lane --- apps/ios/ADE/Models/RemoteModels.swift | 1 + apps/ios/ADE/Services/Database.swift | 6 ++- .../ADE/Views/Work/WorkNewChatScreen.swift | 49 ++++++++++++++++++- apps/ios/ADETests/ADETests.swift | 48 ++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) 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/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index ad3ce23b0..c094ce9fb 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -39,6 +39,45 @@ 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 workPath(_ candidate: String, isEqualToOrInside root: String) -> Bool { + candidate == root || candidate.hasPrefix(root + "/") +} + +func workShellProjectScope( + for lane: LaneSummary, + projects: [MobileProjectSummary], + fallbackProjectId: String?, + fallbackProjectRootPath: String? +) -> WorkProjectCommandScope { + let laneProjectId = workNonEmptyScopeValue(lane.projectId) + let projectById = laneProjectId.flatMap { id in projects.first { $0.id == id } } + let laneRoots = [lane.attachedRootPath, lane.worktreePath] + .compactMap(syncNormalizedProjectRootScope) + + let projectByLanePath = projects.first { project in + guard let root = syncNormalizedProjectRootScope(project.rootPath) else { return false } + return laneRoots.contains { workPath($0, isEqualToOrInside: root) } + } + + let project = projectById ?? projectByLanePath + let projectId = project?.id ?? laneProjectId ?? workNonEmptyScopeValue(fallbackProjectId) + let shouldUseFallbackRoot = laneProjectId == nil && project == nil + let rootPath = syncNormalizedProjectRootScope(project?.rootPath) + ?? (shouldUseFallbackRoot ? syncNormalizedProjectRootScope(fallbackProjectRootPath) : nil) + + return WorkProjectCommandScope(projectId: projectId, projectRootPath: rootPath) +} + @discardableResult @MainActor func workRunAutoLaneAiRename( @@ -895,10 +934,16 @@ struct WorkNewChatScreen: View { defer { shellLaunchBusy = false } do { + let scope = workShellProjectScope( + for: lane, + projects: syncService.projects, + fallbackProjectId: activeProjectId, + fallbackProjectRootPath: activeProjectRootPath + ) let result = try await syncService.startShellSession( laneId: lane.id, - targetProjectId: activeProjectId, - targetProjectRootPath: activeProjectRootPath + targetProjectId: scope.projectId, + targetProjectRootPath: scope.projectRootPath ) if let session = result.session { await onCliStarted(session) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 80873b280..60c88036f 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -517,6 +517,54 @@ final class ADETests: XCTestCase { 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 + ), + ], + fallbackProjectId: "project-active", + fallbackProjectRootPath: "/tmp/project-active" + ) + + 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: [], + fallbackProjectId: "project-active", + fallbackProjectRootPath: "/tmp/project-active" + ) + + XCTAssertEqual(scope.projectId, "project-foreign") + XCTAssertNil(scope.projectRootPath) + } + 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"]) From 5dd7ca1fba63a4b08b5b7e7a5e5a7ee56610a10e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:35:00 -0400 Subject: [PATCH 4/8] ship: iteration 3 - harden queued shell launches --- apps/ios/ADE/Services/SyncService.swift | 50 ++++++++----- .../ADE/Views/Work/WorkNewChatScreen.swift | 34 ++++----- apps/ios/ADETests/ADETests.swift | 70 +++++++++++++++++-- 3 files changed, 115 insertions(+), 39 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 23ace2568..fc5cc426b 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -2811,6 +2811,7 @@ final class SyncService: ObservableObject { let hostId: String? let projectId: String? let projectRootPath: String? + let fallbackToActiveProjectScope: Bool? } init(database: DatabaseService = DatabaseService()) { @@ -5491,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, @@ -5526,6 +5528,7 @@ final class SyncService: ObservableObject { args: args, targetProjectId: targetProjectId, targetProjectRootPath: targetProjectRootPath, + fallbackToActiveProjectScope: fallbackToActiveProjectScope, as: StartCliSessionResult.self ) } @@ -5547,7 +5550,8 @@ final class SyncService: ObservableObject { cols: request.cols, rows: request.rows, targetProjectId: request.targetProjectId, - targetProjectRootPath: request.targetProjectRootPath + targetProjectRootPath: request.targetProjectRootPath, + fallbackToActiveProjectScope: false ) } @@ -8855,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 ) } } @@ -9908,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( @@ -9916,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) @@ -10070,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."]) @@ -10084,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() { @@ -10192,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 { @@ -10248,7 +10258,8 @@ 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 { guard canSendLiveRequests() else { throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "The machine is offline."]) @@ -10258,9 +10269,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, @@ -10289,7 +10301,8 @@ 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 { let commandId = makeRequestId() if canSendLiveRequests() { @@ -10302,7 +10315,8 @@ final class SyncService: ObservableObject { timeoutMessage: timeoutMessage, timeoutNanoseconds: timeoutNanoseconds, targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath + targetProjectRootPath: targetProjectRootPath, + fallbackToActiveProjectScope: fallbackToActiveProjectScope ) } catch { let stillLive = canSendLiveRequests() @@ -10317,7 +10331,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) @@ -10338,7 +10353,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 c094ce9fb..e30f0e767 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -55,9 +55,7 @@ private func workPath(_ candidate: String, isEqualToOrInside root: String) -> Bo func workShellProjectScope( for lane: LaneSummary, - projects: [MobileProjectSummary], - fallbackProjectId: String?, - fallbackProjectRootPath: String? + projects: [MobileProjectSummary] ) -> WorkProjectCommandScope { let laneProjectId = workNonEmptyScopeValue(lane.projectId) let projectById = laneProjectId.flatMap { id in projects.first { $0.id == id } } @@ -70,10 +68,8 @@ func workShellProjectScope( } let project = projectById ?? projectByLanePath - let projectId = project?.id ?? laneProjectId ?? workNonEmptyScopeValue(fallbackProjectId) - let shouldUseFallbackRoot = laneProjectId == nil && project == nil + let projectId = project?.id ?? laneProjectId let rootPath = syncNormalizedProjectRootScope(project?.rootPath) - ?? (shouldUseFallbackRoot ? syncNormalizedProjectRootScope(fallbackProjectRootPath) : nil) return WorkProjectCommandScope(projectId: projectId, projectRootPath: rootPath) } @@ -547,6 +543,7 @@ struct WorkNewChatScreen: View { @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? @@ -814,15 +811,17 @@ struct WorkNewChatScreen: View { private var sessionActionChips: some View { if let lane = selectedConcreteLane { 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, disabled: chipsDisabled) + shellSessionAffordance(isBusy: shellLaunchBusy, isQueued: shellQueued, disabled: shellDisabled) } .buttonStyle(.plain) - .disabled(chipsDisabled) + .disabled(shellDisabled) NavigationLink { WorkImportSessionScreen( @@ -843,18 +842,22 @@ struct WorkNewChatScreen: View { } } - private func shellSessionAffordance(isBusy: Bool, disabled: Bool) -> some 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" : "Shell") + Text(isBusy ? "Starting shell" : (isQueued ? "Shell queued" : "Shell")) .font(.subheadline.weight(.medium)) .foregroundStyle(disabled ? ADEColor.textMuted : ADEColor.textPrimary) } @@ -863,7 +866,7 @@ struct WorkNewChatScreen: View { .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" : "Open shell") + .accessibilityLabel(isBusy ? "Starting shell" : (isQueued ? "Shell queued" : "Open shell")) } private func importSessionAffordance(disabled: Bool) -> some View { @@ -936,9 +939,7 @@ struct WorkNewChatScreen: View { do { let scope = workShellProjectScope( for: lane, - projects: syncService.projects, - fallbackProjectId: activeProjectId, - fallbackProjectRootPath: activeProjectRootPath + projects: syncService.projects ) let result = try await syncService.startShellSession( laneId: lane.id, @@ -974,9 +975,10 @@ struct WorkNewChatScreen: View { chatIdleSinceAt: nil )) } - } catch let queuedError as QueuedRemoteCommandError { + } catch is QueuedRemoteCommandError { ADEHaptics.medium() - errorMessage = queuedError.errorDescription + queuedShellLaneIds.insert(lane.id) + errorMessage = nil } catch { ADEHaptics.error() errorMessage = error.localizedDescription diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 60c88036f..ef6fec47d 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -541,9 +541,7 @@ final class ADETests: XCTestCase { isAvailable: true, isCached: true ), - ], - fallbackProjectId: "project-active", - fallbackProjectRootPath: "/tmp/project-active" + ] ) XCTAssertEqual(scope.projectId, "project-lane") @@ -556,15 +554,75 @@ final class ADETests: XCTestCase { let scope = workShellProjectScope( for: lane, - projects: [], - fallbackProjectId: "project-active", - fallbackProjectRootPath: "/tmp/project-active" + projects: [] ) XCTAssertEqual(scope.projectId, "project-foreign") 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) + } + + @MainActor + func testQueuedShellStartDoesNotFallbackToActiveProjectWhenLaneScopeIsMissing() 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 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") + XCTAssertNil(queued.first?.projectId) + XCTAssertNil(queued.first?.projectRootPath) + XCTAssertEqual(queued.first?.fallbackToActiveProjectScope, false) + } + 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"]) From 16a4259123c7a94e9db600d3aa0efc2d0f2b2324 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:45:18 -0400 Subject: [PATCH 5/8] ship: iteration 4 - require shell project scope --- apps/ios/ADE/Services/SyncService.swift | 6 ++++ apps/ios/ADETests/ADETests.swift | 48 ++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index fc5cc426b..aa924f8da 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -10261,6 +10261,9 @@ final class SyncService: ObservableObject { 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."]) } @@ -10304,6 +10307,9 @@ final class SyncService: ObservableObject { 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 { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index ef6fec47d..6275110ab 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -583,7 +583,7 @@ final class ADETests: XCTestCase { } @MainActor - func testQueuedShellStartDoesNotFallbackToActiveProjectWhenLaneScopeIsMissing() async throws { + func testQueuedShellStartUsesLaneProjectScopeWithoutActiveFallback() async throws { let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" let pendingOperationsKey = "ade.sync.pendingOperations" UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) @@ -608,7 +608,11 @@ final class ADETests: XCTestCase { service.disconnect() do { - _ = try await service.startShellSession(laneId: "lane-missing") + _ = 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. @@ -618,11 +622,47 @@ final class ADETests: XCTestCase { XCTAssertEqual(queued.count, 1) XCTAssertEqual(queued.first?.kind, "command") XCTAssertEqual(queued.first?.action, "work.startCliSession") - XCTAssertNil(queued.first?.projectId) - XCTAssertNil(queued.first?.projectRootPath) + 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"]) From 1c1e78678aabc17d2babc67c337725fd6229be60 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:53:02 -0400 Subject: [PATCH 6/8] ship: iteration 5 - prefer nested shell scope --- .../ADE/Views/Work/WorkNewChatScreen.swift | 11 +++++-- apps/ios/ADETests/ADETests.swift | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index e30f0e767..05c946adc 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -62,10 +62,15 @@ func workShellProjectScope( let laneRoots = [lane.attachedRootPath, lane.worktreePath] .compactMap(syncNormalizedProjectRootScope) - let projectByLanePath = projects.first { project in - guard let root = syncNormalizedProjectRootScope(project.rootPath) else { return false } - return laneRoots.contains { workPath($0, isEqualToOrInside: root) } + let projectByLanePath = projects.compactMap { project -> (project: MobileProjectSummary, root: String)? in + guard let root = syncNormalizedProjectRootScope(project.rootPath), + laneRoots.contains(where: { workPath($0, isEqualToOrInside: root) }) + else { return nil } + return (project, root) } + .max { left, right in + left.root.count < right.root.count + }?.project let project = projectById ?? projectByLanePath let projectId = project?.id ?? laneProjectId diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 6275110ab..02559ebfd 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -582,6 +582,36 @@ final class ADETests: XCTestCase { 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") + } + @MainActor func testQueuedShellStartUsesLaneProjectScopeWithoutActiveFallback() async throws { let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" From 5bf46f6a5df266dbe2e95a90a877d0d52f9fe285 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:01:22 -0400 Subject: [PATCH 7/8] ship: iteration 6 - keep known shell project --- .../ADE/Views/Work/WorkNewChatScreen.swift | 4 ++-- apps/ios/ADETests/ADETests.swift | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 05c946adc..0cc748b1b 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -72,8 +72,8 @@ func workShellProjectScope( left.root.count < right.root.count }?.project - let project = projectById ?? projectByLanePath - let projectId = project?.id ?? laneProjectId + let project = laneProjectId == nil ? projectByLanePath : projectById + let projectId = laneProjectId ?? project?.id let rootPath = syncNormalizedProjectRootScope(project?.rootPath) return WorkProjectCommandScope(projectId: projectId, projectRootPath: rootPath) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 02559ebfd..e070af636 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -561,6 +561,29 @@ final class ADETests: XCTestCase { 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") From f59ecc4fb6cbcc9c17aab23090275733f4205175 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:10:16 -0400 Subject: [PATCH 8/8] ship: iteration 7 - reject stale parent shell scope --- .../ADE/Views/Work/WorkNewChatScreen.swift | 36 ++++++++++++------- apps/ios/ADETests/ADETests.swift | 22 ++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 0cc748b1b..b8c49bb63 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -49,8 +49,21 @@ private func workNonEmptyScopeValue(_ value: String?) -> String? { return trimmed.isEmpty ? nil : trimmed } -private func workPath(_ candidate: String, isEqualToOrInside root: String) -> Bool { - candidate == root || candidate.hasPrefix(root + "/") +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( @@ -59,18 +72,15 @@ func workShellProjectScope( ) -> WorkProjectCommandScope { let laneProjectId = workNonEmptyScopeValue(lane.projectId) let projectById = laneProjectId.flatMap { id in projects.first { $0.id == id } } - let laneRoots = [lane.attachedRootPath, lane.worktreePath] - .compactMap(syncNormalizedProjectRootScope) - - let projectByLanePath = projects.compactMap { project -> (project: MobileProjectSummary, root: String)? in - guard let root = syncNormalizedProjectRootScope(project.rootPath), - laneRoots.contains(where: { workPath($0, isEqualToOrInside: root) }) - else { return nil } - return (project, root) + 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) } - .max { left, right in - left.root.count < right.root.count - }?.project let project = laneProjectId == nil ? projectByLanePath : projectById let projectId = laneProjectId ?? project?.id diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index e070af636..198edc1e4 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -635,6 +635,28 @@ final class ADETests: XCTestCase { 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"