From 54625f82647edcc1ed4f7ea28830423880e82fca Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:04:01 -0600 Subject: [PATCH 1/3] feat: implement Spawn view for iOS app SwiftUI form for spawning new worktrees with agents: - Name field (required, used as branch suffix) - Multi-line prompt TextEditor - Agent type picker (claude/codex/opencode) - Count stepper (1-10) - Base branch picker (derived from manifest) - Quick prompt templates section with toggle selection - Form validation (name required, prompt or template required) - Loading state during spawn with disabled controls - Clear form on success - Navigate to WorktreeDetailView on completion Closes #85 --- .../PPGMobile/Views/Spawn/SpawnView.swift | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift diff --git a/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift b/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift new file mode 100644 index 0000000..19ad6ed --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift @@ -0,0 +1,220 @@ +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? + @State private var showResult = false + + private var isFormValid: Bool { + let hasName = !name.trimmingCharacters(in: .whitespaces).isEmpty + 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() + 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 + } + .navigationTitle("Spawn") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + spawnButton + } + } + .navigationDestination(isPresented: $showResult) { + if let worktree = spawnedWorktree { + WorktreeDetailView(worktree: worktree) + } + } + } + } + + // MARK: - Sections + + private var nameSection: some View { + Section { + TextField("Worktree name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } header: { + Text("Name") + } footer: { + Text("Required. Used as the branch suffix (ppg/)") + } + } + + 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 trimmedName = name.trimmingCharacters(in: .whitespaces) + let trimmedPrompt = prompt.trimmingCharacters(in: .whitespaces) + let promptText = trimmedPrompt.isEmpty + ? (selectedTemplate ?? "") + : trimmedPrompt + + do { + let response = try await appState.client.spawn( + name: trimmedName, + agent: selectedVariant.rawValue, + prompt: promptText, + template: selectedTemplate, + base: baseBranch.isEmpty ? nil : baseBranch, + count: count + ) + + await appState.manifestStore.refresh() + + if let newWorktree = appState.manifestStore.manifest?.worktrees[response.worktree.id] { + spawnedWorktree = newWorktree + clearForm() + showResult = true + } else { + clearForm() + } + } catch { + errorMessage = error.localizedDescription + } + + isSpawning = false + } + + private func clearForm() { + name = "" + prompt = "" + selectedVariant = .claude + count = 1 + baseBranch = "" + selectedTemplate = nil + errorMessage = nil + } +} From a9aab02de35ffe60526056a1994523abc329aebf Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:49:42 -0600 Subject: [PATCH 2/3] fix: address code review findings in SpawnView - Add .scrollDismissesKeyboard(.interactively) for keyboard dismiss - Add .disabled(isSpawning) on Form to prevent edits during spawn - Replace navigationDestination(isPresented:) with item: overload to eliminate showResult state and prevent blank navigation edge case - Pass trimmedPrompt directly instead of falling back to template name as prompt text (template is sent separately) - Add client-side name validation (alphanumeric + hyphens) with inline error message in footer --- .../PPGMobile/Views/Spawn/SpawnView.swift | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift b/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift index 19ad6ed..bfb14bf 100644 --- a/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Spawn/SpawnView.swift @@ -15,10 +15,15 @@ struct SpawnView: View { @State private var isSpawning = false @State private var errorMessage: String? @State private var spawnedWorktree: WorktreeEntry? - @State private var showResult = false + + 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 = !name.trimmingCharacters(in: .whitespaces).isEmpty + 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) @@ -49,16 +54,16 @@ struct SpawnView: View { baseBranchSection errorSection } + .scrollDismissesKeyboard(.interactively) + .disabled(isSpawning) .navigationTitle("Spawn") .toolbar { ToolbarItem(placement: .topBarTrailing) { spawnButton } } - .navigationDestination(isPresented: $showResult) { - if let worktree = spawnedWorktree { - WorktreeDetailView(worktree: worktree) - } + .navigationDestination(item: $spawnedWorktree) { worktree in + WorktreeDetailView(worktree: worktree) } } } @@ -73,7 +78,12 @@ struct SpawnView: View { } header: { Text("Name") } footer: { - Text("Required. Used as the branch suffix (ppg/)") + 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/)") + } } } @@ -176,17 +186,13 @@ struct SpawnView: View { isSpawning = true errorMessage = nil - let trimmedName = name.trimmingCharacters(in: .whitespaces) let trimmedPrompt = prompt.trimmingCharacters(in: .whitespaces) - let promptText = trimmedPrompt.isEmpty - ? (selectedTemplate ?? "") - : trimmedPrompt do { let response = try await appState.client.spawn( - name: trimmedName, + name: sanitizedName, agent: selectedVariant.rawValue, - prompt: promptText, + prompt: trimmedPrompt, template: selectedTemplate, base: baseBranch.isEmpty ? nil : baseBranch, count: count @@ -194,13 +200,10 @@ struct SpawnView: View { await appState.manifestStore.refresh() - if let newWorktree = appState.manifestStore.manifest?.worktrees[response.worktree.id] { - spawnedWorktree = newWorktree - clearForm() - showResult = true - } else { - clearForm() - } + 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 } From 52e61fd1a285a275a39eeb52ee025e05296ab7d6 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:36:59 -0600 Subject: [PATCH 3/3] test: fix strict manifest typing in spawn test --- src/commands/spawn.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..e29d746 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -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 () => { @@ -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',