Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
223 changes: 223 additions & 0 deletions ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import SwiftUI

struct SpawnView: View {
@Environment(AppState.self) private var appState

// Form fields
@State private var name = ""
@State private var prompt = ""
@State private var selectedVariant: AgentVariant = .claude
@State private var count = 1
@State private var baseBranch = ""
@State private var selectedTemplate: String?

// UI state
@State private var isSpawning = false
@State private var errorMessage: String?
@State private var spawnedWorktree: WorktreeEntry?

private static let namePattern = /^[a-zA-Z0-9][a-zA-Z0-9\-]*$/

private var sanitizedName: String {
name.trimmingCharacters(in: .whitespaces)
}

private var isFormValid: Bool {
let hasName = !sanitizedName.isEmpty && sanitizedName.wholeMatch(of: Self.namePattern) != nil
let hasPrompt = !prompt.trimmingCharacters(in: .whitespaces).isEmpty
let hasTemplate = selectedTemplate != nil
return hasName && (hasPrompt || hasTemplate)
}

private var spawnableVariants: [AgentVariant] {
[.claude, .codex, .opencode]
}

private var availableBranches: [String] {
var branches = Set<String>()
branches.insert("main")
if let manifest = appState.manifestStore.manifest {
for wt in manifest.worktrees.values {
branches.insert(wt.baseBranch)
}
}
return branches.sorted()
}

var body: some View {
NavigationStack {
Form {
nameSection
agentSection
promptSection
templatesSection
baseBranchSection
errorSection
}
.scrollDismissesKeyboard(.interactively)
.disabled(isSpawning)
.navigationTitle("Spawn")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
spawnButton
}
}
.navigationDestination(item: $spawnedWorktree) { worktree in
WorktreeDetailView(worktree: worktree)
}
}
}

// MARK: - Sections

private var nameSection: some View {
Section {
TextField("Worktree name", text: $name)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Name")
} footer: {
if !sanitizedName.isEmpty && sanitizedName.wholeMatch(of: Self.namePattern) == nil {
Text("Only letters, numbers, and hyphens allowed")
.foregroundStyle(.red)
} else {
Text("Required. Letters, numbers, and hyphens (ppg/<name>)")
}
}
}

private var agentSection: some View {
Section("Agent") {
Picker("Type", selection: $selectedVariant) {
ForEach(spawnableVariants, id: \.self) { variant in
Label(variant.displayName, systemImage: variant.icon)
.tag(variant)
}
}

Stepper("Count: \(count)", value: $count, in: 1...10)
}
}

private var promptSection: some View {
Section {
TextEditor(text: $prompt)
.frame(minHeight: 120)
.font(.body)
} header: {
Text("Prompt")
} footer: {
if selectedTemplate != nil {
Text("Template selected — prompt is optional")
} else {
Text("Required if no template is selected")
}
}
}

@ViewBuilder
private var templatesSection: some View {
if !appState.templates.isEmpty {
Section("Quick Templates") {
ForEach(appState.templates, id: \.self) { template in
Button {
withAnimation {
selectedTemplate = selectedTemplate == template ? nil : template
}
} label: {
HStack {
Image(systemName: "doc.text")
Text(template)
Spacer()
if selectedTemplate == template {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
}
.tint(.primary)
}
}
}
}

private var baseBranchSection: some View {
Section {
Picker("Base branch", selection: $baseBranch) {
Text("Default (current)").tag("")
ForEach(availableBranches, id: \.self) { branch in
Text(branch).tag(branch)
}
}
} footer: {
Text("Branch to create the worktree from")
}
}

@ViewBuilder
private var errorSection: some View {
if let errorMessage {
Section {
Label(errorMessage, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
}

private var spawnButton: some View {
Button {
Task { await spawnWorktree() }
} label: {
if isSpawning {
ProgressView()
} else {
Text("Spawn")
.bold()
}
}
.disabled(!isFormValid || isSpawning)
}

// MARK: - Actions

@MainActor
private func spawnWorktree() async {
isSpawning = true
errorMessage = nil

let trimmedPrompt = prompt.trimmingCharacters(in: .whitespaces)

do {
let response = try await appState.client.spawn(
name: sanitizedName,
agent: selectedVariant.rawValue,
prompt: trimmedPrompt,
template: selectedTemplate,
base: baseBranch.isEmpty ? nil : baseBranch,
count: count
)

await appState.manifestStore.refresh()

let newWorktree = appState.manifestStore.manifest?.worktrees[response.worktree.id]
clearForm()
// Set after clearing so navigation triggers with the worktree
spawnedWorktree = newWorktree
} catch {
errorMessage = error.localizedDescription
}

isSpawning = false
}

private func clearForm() {
name = ""
prompt = ""
selectedVariant = .claude
count = 1
baseBranch = ""
selectedTemplate = nil
errorMessage = nil
}
}
3 changes: 2 additions & 1 deletion src/commands/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readManifest, resolveWorktree, updateManifest } from '../core/manifest.
import { spawnAgent } from '../core/agent.js';
import { getRepoRoot } from '../core/worktree.js';
import { agentId, sessionId } from '../lib/id.js';
import type { Manifest } from '../types/manifest.js';
import * as tmux from '../core/tmux.js';

vi.mock('node:fs/promises', async () => {
Expand Down Expand Up @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession);
const mockedCreateWindow = vi.mocked(tmux.createWindow);
const mockedSplitPane = vi.mocked(tmux.splitPane);

function createManifest(tmuxWindow = '') {
function createManifest(tmuxWindow = ''): Manifest {
return {
version: 1 as const,
projectRoot: '/tmp/repo',
Expand Down