Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/ios/ADE/Services/SyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
94 changes: 88 additions & 6 deletions apps/ios/ADE/Views/Work/WorkNewChatScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -651,7 +652,7 @@ struct WorkNewChatScreen: View {
.padding(.bottom, 6)
}

importSessionChip
sessionActionChips

composerBar
}
Expand Down Expand Up @@ -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,
Expand All @@ -780,16 +790,40 @@ struct WorkNewChatScreen: View {
)
.environmentObject(syncService)
} label: {
importSessionAffordance(disabled: false)
importSessionAffordance(disabled: chipsDisabled)
}
.buttonStyle(.plain)
.disabled(chipsDisabled)
Spacer(minLength: 0)
}
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
}

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")
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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
Comment thread
arul28 marked this conversation as resolved.
Outdated
} 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.
Expand Down Expand Up @@ -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"
}
}
Expand Down
9 changes: 9 additions & 0 deletions apps/ios/ADETests/ADETests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down