From 9843c5b10930406e44fa7a8b5350f6527888007b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:01:46 -0600 Subject: [PATCH 1/3] feat: implement Dashboard views for iOS app Add SwiftUI views for the main dashboard experience: - DashboardView: NavigationStack with Active/Completed worktree sections, pull-to-refresh, connection status indicator, empty and disconnected states - WorktreeCard: status card with name, branch, agent count, status badge - WorktreeDetailView: inspector with agent list, diff stats, merge/kill/PR actions - AgentRow: agent status row with kill/restart action buttons Includes @Observable DashboardStore protocol and domain models (Worktree, Agent, WorktreeStatus, AgentStatus) aligned with the macOS app's model layer. Closes #83 --- .../PPGMobile/Views/Dashboard/AgentRow.swift | 125 +++++++ .../Views/Dashboard/DashboardView.swift | 325 ++++++++++++++++++ .../Views/Dashboard/WorktreeCard.swift | 109 ++++++ .../Views/Dashboard/WorktreeDetailView.swift | 167 +++++++++ 4 files changed, 726 insertions(+) create mode 100644 ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift create mode 100644 ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift new file mode 100644 index 0000000..51e2b41 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct AgentRow: View { + let agent: Agent + var onKill: (() -> Void)? + var onRestart: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: agent.status.icon) + .foregroundStyle(agent.status.color) + .font(.body) + + VStack(alignment: .leading, spacing: 1) { + Text(agent.name) + .font(.subheadline) + .fontWeight(.medium) + + Text(agent.agentType) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + statusLabel + } + + Text(agent.prompt) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + HStack { + Text(agent.startedAt, style: .relative) + .font(.caption2) + .foregroundStyle(.tertiary) + + if let error = agent.error { + Text(error) + .font(.caption2) + .foregroundStyle(.red) + .lineLimit(1) + } + + Spacer() + + actionButtons + } + } + .padding(.vertical, 4) + } + + // MARK: - Status Label + + private var statusLabel: some View { + Text(agent.status.label) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(agent.status.color.opacity(0.12)) + .foregroundStyle(agent.status.color) + .clipShape(Capsule()) + } + + // MARK: - Action Buttons + + @ViewBuilder + private var actionButtons: some View { + HStack(spacing: 12) { + if agent.status.isActive { + Button { + onKill?() + } label: { + Image(systemName: "stop.fill") + .font(.caption) + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + } + + if agent.status == .failed || agent.status == .killed { + Button { + onRestart?() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.caption) + .foregroundStyle(.blue) + } + .buttonStyle(.borderless) + } + } + } +} + +#Preview { + List { + AgentRow( + agent: Agent(id: "ag-1", name: "claude-1", agentType: "claude", status: .running, prompt: "Implement the authentication flow with JWT tokens", startedAt: .now.addingTimeInterval(-300), completedAt: nil, exitCode: nil, error: nil), + onKill: {}, + onRestart: {} + ) + + AgentRow( + agent: Agent(id: "ag-2", name: "claude-2", agentType: "claude", status: .completed, prompt: "Write unit tests for the auth module", startedAt: .now.addingTimeInterval(-600), completedAt: .now.addingTimeInterval(-120), exitCode: 0, error: nil), + onKill: {}, + onRestart: {} + ) + + AgentRow( + agent: Agent(id: "ag-3", name: "codex-1", agentType: "codex", status: .failed, prompt: "Set up middleware pipeline", startedAt: .now.addingTimeInterval(-500), completedAt: .now.addingTimeInterval(-200), exitCode: 1, error: "Process exited with code 1"), + onKill: {}, + onRestart: {} + ) + + AgentRow( + agent: Agent(id: "ag-4", name: "claude-3", agentType: "claude", status: .killed, prompt: "Refactor database layer", startedAt: .now.addingTimeInterval(-900), completedAt: nil, exitCode: nil, error: nil), + onKill: {}, + onRestart: {} + ) + } + .listStyle(.insetGrouped) +} diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..2eaea79 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift @@ -0,0 +1,325 @@ +import SwiftUI + +struct DashboardView: View { + @Bindable var store: DashboardStore + + var body: some View { + NavigationStack { + Group { + switch store.connectionState { + case .disconnected: + disconnectedView + case .connecting: + ProgressView("Connecting...") + case .connected: + if store.worktrees.isEmpty { + emptyStateView + } else { + worktreeList + } + } + } + .navigationTitle(store.projectName) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + connectionIndicator + } + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await store.refresh() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(store.connectionState != .connected) + } + } + } + } + + // MARK: - Worktree List + + private var worktreeList: some View { + List { + if !activeWorktrees.isEmpty { + Section("Active") { + ForEach(activeWorktrees) { worktree in + NavigationLink(value: worktree.id) { + WorktreeCard(worktree: worktree) + } + } + } + } + + if !completedWorktrees.isEmpty { + Section("Completed") { + ForEach(completedWorktrees) { worktree in + NavigationLink(value: worktree.id) { + WorktreeCard(worktree: worktree) + } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await store.refresh() + } + .navigationDestination(for: String.self) { worktreeId in + if let worktree = store.worktrees.first(where: { $0.id == worktreeId }) { + WorktreeDetailView(worktree: worktree, store: store) + } + } + } + + // MARK: - Empty State + + private var emptyStateView: some View { + ContentUnavailableView { + Label("No Worktrees", systemImage: "arrow.triangle.branch") + } description: { + Text("Spawn agents from the CLI to see them here.") + } actions: { + Button("Refresh") { + Task { await store.refresh() } + } + } + } + + // MARK: - Disconnected State + + private var disconnectedView: some View { + ContentUnavailableView { + Label("Disconnected", systemImage: "wifi.slash") + } description: { + Text("Unable to reach the ppg service. Check that the CLI is running and the server is started.") + } actions: { + Button("Retry") { + Task { await store.connect() } + } + .buttonStyle(.borderedProminent) + } + } + + // MARK: - Connection Indicator + + private var connectionIndicator: some View { + HStack(spacing: 6) { + Circle() + .fill(connectionColor) + .frame(width: 8, height: 8) + Text(connectionLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // MARK: - Helpers + + private var activeWorktrees: [Worktree] { + store.worktrees.filter { !$0.status.isTerminal } + } + + private var completedWorktrees: [Worktree] { + store.worktrees.filter { $0.status.isTerminal } + } + + private var connectionColor: Color { + switch store.connectionState { + case .connected: .green + case .connecting: .yellow + case .disconnected: .red + } + } + + private var connectionLabel: String { + switch store.connectionState { + case .connected: "Connected" + case .connecting: "Connecting" + case .disconnected: "Disconnected" + } + } +} + +// MARK: - Domain Models + +enum ConnectionState { + case disconnected + case connecting + case connected +} + +struct Worktree: Identifiable { + let id: String + let name: String + let branch: String + let path: String + let status: WorktreeStatus + let agents: [Agent] + let createdAt: Date + let mergedAt: Date? +} + +enum WorktreeStatus: String { + case spawning + case running + case merged + case cleaned + case merging + + var isTerminal: Bool { + self == .merged || self == .cleaned + } + + var label: String { rawValue.capitalized } + + var color: Color { + switch self { + case .spawning: .yellow + case .running: .green + case .merging: .orange + case .merged: .blue + case .cleaned: .secondary + } + } + + var icon: String { + switch self { + case .spawning: "hourglass" + case .running: "play.circle.fill" + case .merging: "arrow.triangle.merge" + case .merged: "checkmark.circle.fill" + case .cleaned: "archivebox" + } + } +} + +struct Agent: Identifiable { + let id: String + let name: String + let agentType: String + let status: AgentStatus + let prompt: String + let startedAt: Date + let completedAt: Date? + let exitCode: Int? + let error: String? +} + +enum AgentStatus: String, CaseIterable { + case spawning + case running + case waiting + case completed + case failed + case killed + case lost + + var label: String { rawValue.capitalized } + + var color: Color { + switch self { + case .running: .green + case .completed: .blue + case .failed: .red + case .killed: .orange + case .spawning: .yellow + case .waiting, .lost: .secondary + } + } + + var icon: String { + switch self { + case .spawning: "hourglass" + case .running: "play.circle.fill" + case .waiting: "pause.circle" + case .completed: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .killed: "stop.circle.fill" + case .lost: "questionmark.circle" + } + } + + var isActive: Bool { + self == .spawning || self == .running || self == .waiting + } +} + +// MARK: - Store Protocol + +@Observable +class DashboardStore { + var projectName: String = "" + var worktrees: [Worktree] = [] + var connectionState: ConnectionState = .disconnected + + func refresh() async {} + func connect() async {} + func killAgent(_ agentId: String, in worktreeId: String) async {} + func restartAgent(_ agentId: String, in worktreeId: String) async {} + func mergeWorktree(_ worktreeId: String) async {} + func killWorktree(_ worktreeId: String) async {} +} + +#Preview("Connected with worktrees") { + DashboardView(store: .preview) +} + +#Preview("Empty state") { + DashboardView(store: .previewEmpty) +} + +#Preview("Disconnected") { + DashboardView(store: .previewDisconnected) +} + +// MARK: - Preview Helpers + +extension DashboardStore { + static var preview: DashboardStore { + let store = DashboardStore() + store.projectName = "my-project" + store.connectionState = .connected + store.worktrees = [ + Worktree( + id: "wt-abc123", + name: "auth-feature", + branch: "ppg/auth-feature", + path: ".worktrees/wt-abc123", + status: .running, + agents: [ + Agent(id: "ag-11111111", name: "claude-1", agentType: "claude", status: .running, prompt: "Implement auth", startedAt: .now.addingTimeInterval(-300), completedAt: nil, exitCode: nil, error: nil), + Agent(id: "ag-22222222", name: "claude-2", agentType: "claude", status: .completed, prompt: "Write tests", startedAt: .now.addingTimeInterval(-600), completedAt: .now.addingTimeInterval(-120), exitCode: 0, error: nil), + ], + createdAt: .now.addingTimeInterval(-3600), + mergedAt: nil + ), + Worktree( + id: "wt-def456", + name: "fix-bug", + branch: "ppg/fix-bug", + path: ".worktrees/wt-def456", + status: .merged, + agents: [ + Agent(id: "ag-33333333", name: "codex-1", agentType: "codex", status: .completed, prompt: "Fix the login bug", startedAt: .now.addingTimeInterval(-7200), completedAt: .now.addingTimeInterval(-3600), exitCode: 0, error: nil), + ], + createdAt: .now.addingTimeInterval(-86400), + mergedAt: .now.addingTimeInterval(-3600) + ), + ] + return store + } + + static var previewEmpty: DashboardStore { + let store = DashboardStore() + store.projectName = "new-project" + store.connectionState = .connected + return store + } + + static var previewDisconnected: DashboardStore { + let store = DashboardStore() + store.projectName = "my-project" + store.connectionState = .disconnected + return store + } +} diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift new file mode 100644 index 0000000..1bb9352 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct WorktreeCard: View { + let worktree: Worktree + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(worktree.name) + .font(.headline) + + Text(worktree.branch) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + statusBadge + } + + HStack(spacing: 12) { + Label("\(worktree.agents.count)", systemImage: "person.2") + .font(.subheadline) + .foregroundStyle(.secondary) + + if !activeAgents.isEmpty { + Label("\(activeAgents.count) active", systemImage: "bolt.fill") + .font(.caption) + .foregroundStyle(.green) + } + + if !failedAgents.isEmpty { + Label("\(failedAgents.count) failed", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + } + + Spacer() + + Text(worktree.createdAt, style: .relative) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 4) + } + + // MARK: - Status Badge + + private var statusBadge: some View { + HStack(spacing: 4) { + Image(systemName: worktree.status.icon) + .font(.caption2) + Text(worktree.status.label) + .font(.caption) + .fontWeight(.medium) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(worktree.status.color.opacity(0.15)) + .foregroundStyle(worktree.status.color) + .clipShape(Capsule()) + } + + // MARK: - Helpers + + private var activeAgents: [Agent] { + worktree.agents.filter { $0.status.isActive } + } + + private var failedAgents: [Agent] { + worktree.agents.filter { $0.status == .failed } + } +} + +#Preview { + List { + WorktreeCard(worktree: Worktree( + id: "wt-abc123", + name: "auth-feature", + branch: "ppg/auth-feature", + path: ".worktrees/wt-abc123", + status: .running, + agents: [ + Agent(id: "ag-1", name: "claude-1", agentType: "claude", status: .running, prompt: "Implement auth", startedAt: .now, completedAt: nil, exitCode: nil, error: nil), + Agent(id: "ag-2", name: "claude-2", agentType: "claude", status: .completed, prompt: "Write tests", startedAt: .now, completedAt: .now, exitCode: 0, error: nil), + ], + createdAt: .now.addingTimeInterval(-3600), + mergedAt: nil + )) + + WorktreeCard(worktree: Worktree( + id: "wt-def456", + name: "fix-bug", + branch: "ppg/fix-bug", + path: ".worktrees/wt-def456", + status: .merged, + agents: [ + Agent(id: "ag-3", name: "codex-1", agentType: "codex", status: .completed, prompt: "Fix bug", startedAt: .now, completedAt: .now, exitCode: 0, error: nil), + ], + createdAt: .now.addingTimeInterval(-86400), + mergedAt: .now.addingTimeInterval(-3600) + )) + } + .listStyle(.insetGrouped) +} diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift new file mode 100644 index 0000000..5c53a87 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift @@ -0,0 +1,167 @@ +import SwiftUI + +struct WorktreeDetailView: View { + let worktree: Worktree + @Bindable var store: DashboardStore + + @State private var confirmingMerge = false + @State private var confirmingKill = false + + var body: some View { + List { + infoSection + agentsSection + actionsSection + } + .listStyle(.insetGrouped) + .navigationTitle(worktree.name) + .navigationBarTitleDisplayMode(.large) + .confirmationDialog("Merge Worktree", isPresented: $confirmingMerge) { + Button("Squash Merge") { + Task { await store.mergeWorktree(worktree.id) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Merge \"\(worktree.name)\" back to the base branch?") + } + .confirmationDialog("Kill Worktree", isPresented: $confirmingKill) { + Button("Kill All Agents", role: .destructive) { + Task { await store.killWorktree(worktree.id) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Kill all agents in \"\(worktree.name)\"? This cannot be undone.") + } + } + + // MARK: - Info Section + + private var infoSection: some View { + Section { + LabeledContent("Status") { + HStack(spacing: 4) { + Image(systemName: worktree.status.icon) + .font(.caption2) + Text(worktree.status.label) + .fontWeight(.medium) + } + .foregroundStyle(worktree.status.color) + } + + LabeledContent("Branch") { + Text(worktree.branch) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + } + + LabeledContent("Agents") { + Text("\(worktree.agents.count)") + } + + LabeledContent("Created") { + Text(worktree.createdAt, style: .relative) + } + + if let mergedAt = worktree.mergedAt { + LabeledContent("Merged") { + Text(mergedAt, style: .relative) + } + } + } header: { + Text("Details") + } + } + + // MARK: - Agents Section + + private var agentsSection: some View { + Section { + if worktree.agents.isEmpty { + Text("No agents") + .foregroundStyle(.secondary) + } else { + ForEach(worktree.agents) { agent in + AgentRow( + agent: agent, + onKill: { + Task { await store.killAgent(agent.id, in: worktree.id) } + }, + onRestart: { + Task { await store.restartAgent(agent.id, in: worktree.id) } + } + ) + } + } + } header: { + HStack { + Text("Agents") + Spacer() + Text(agentSummary) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Actions Section + + private var actionsSection: some View { + Section { + if worktree.status == .running { + Button { + confirmingMerge = true + } label: { + Label("Merge Worktree", systemImage: "arrow.triangle.merge") + } + + Button(role: .destructive) { + confirmingKill = true + } label: { + Label("Kill All Agents", systemImage: "xmark.octagon") + } + } + + Button { + // PR creation — will be wired to store action + } label: { + Label("Create Pull Request", systemImage: "arrow.triangle.pull") + } + .disabled(worktree.status != .running && worktree.status != .merged) + } header: { + Text("Actions") + } + } + + // MARK: - Helpers + + private var agentSummary: String { + let active = worktree.agents.filter { $0.status.isActive }.count + let total = worktree.agents.count + if active > 0 { + return "\(active)/\(total) active" + } + return "\(total) total" + } +} + +#Preview { + NavigationStack { + WorktreeDetailView( + worktree: Worktree( + id: "wt-abc123", + name: "auth-feature", + branch: "ppg/auth-feature", + path: ".worktrees/wt-abc123", + status: .running, + agents: [ + Agent(id: "ag-11111111", name: "claude-1", agentType: "claude", status: .running, prompt: "Implement OAuth2 authentication flow with JWT tokens", startedAt: .now.addingTimeInterval(-300), completedAt: nil, exitCode: nil, error: nil), + Agent(id: "ag-22222222", name: "claude-2", agentType: "claude", status: .completed, prompt: "Write integration tests for auth", startedAt: .now.addingTimeInterval(-600), completedAt: .now.addingTimeInterval(-120), exitCode: 0, error: nil), + Agent(id: "ag-33333333", name: "codex-1", agentType: "codex", status: .failed, prompt: "Set up auth middleware", startedAt: .now.addingTimeInterval(-500), completedAt: .now.addingTimeInterval(-200), exitCode: 1, error: "Process exited with code 1"), + ], + createdAt: .now.addingTimeInterval(-3600), + mergedAt: nil + ), + store: .preview + ) + } +} From 3ba691e268732d584fcece49c1ab7b73a7c5285b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:50:00 -0600 Subject: [PATCH 2/3] fix: address code review findings for Dashboard views - Extract domain models to Models/DashboardModels.swift (was embedded in DashboardView.swift) - Add DiffStats model and Changes section to WorktreeDetailView - Fix stale worktree data: WorktreeDetailView now takes worktreeId and derives worktree from store, so refreshes propagate to detail view - Add else branch to navigationDestination for missing worktree IDs - Add confirmation dialog to individual agent kill in AgentRow - Add store.createPullRequest(for:) and wire PR button with TODO - Gate all #Preview blocks and preview helpers with #if DEBUG - Add worktree(by:) lookup helper to DashboardStore --- .../PPGMobile/Models/DashboardModels.swift | 193 ++++++++++++++++++ .../PPGMobile/Views/Dashboard/AgentRow.swift | 14 +- .../Views/Dashboard/DashboardView.swift | 184 +---------------- .../Views/Dashboard/WorktreeCard.swift | 4 + .../Views/Dashboard/WorktreeDetailView.swift | 120 +++++++---- 5 files changed, 295 insertions(+), 220 deletions(-) create mode 100644 ios/PPGMobile/PPGMobile/Models/DashboardModels.swift diff --git a/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift b/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift new file mode 100644 index 0000000..9163d35 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift @@ -0,0 +1,193 @@ +import SwiftUI + +// MARK: - Connection + +enum ConnectionState { + case disconnected + case connecting + case connected +} + +// MARK: - Worktree + +struct Worktree: Identifiable { + let id: String + let name: String + let branch: String + let path: String + let status: WorktreeStatus + let agents: [Agent] + let diffStats: DiffStats? + let createdAt: Date + let mergedAt: Date? +} + +struct DiffStats { + let filesChanged: Int + let insertions: Int + let deletions: Int +} + +enum WorktreeStatus: String { + case spawning + case running + case merged + case cleaned + case merging + + var isTerminal: Bool { + self == .merged || self == .cleaned + } + + var label: String { rawValue.capitalized } + + var color: Color { + switch self { + case .spawning: .yellow + case .running: .green + case .merging: .orange + case .merged: .blue + case .cleaned: .secondary + } + } + + var icon: String { + switch self { + case .spawning: "hourglass" + case .running: "play.circle.fill" + case .merging: "arrow.triangle.merge" + case .merged: "checkmark.circle.fill" + case .cleaned: "archivebox" + } + } +} + +// MARK: - Agent + +struct Agent: Identifiable { + let id: String + let name: String + let agentType: String + let status: AgentStatus + let prompt: String + let startedAt: Date + let completedAt: Date? + let exitCode: Int? + let error: String? +} + +enum AgentStatus: String, CaseIterable { + case spawning + case running + case waiting + case completed + case failed + case killed + case lost + + var label: String { rawValue.capitalized } + + var color: Color { + switch self { + case .running: .green + case .completed: .blue + case .failed: .red + case .killed: .orange + case .spawning: .yellow + case .waiting, .lost: .secondary + } + } + + var icon: String { + switch self { + case .spawning: "hourglass" + case .running: "play.circle.fill" + case .waiting: "pause.circle" + case .completed: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .killed: "stop.circle.fill" + case .lost: "questionmark.circle" + } + } + + var isActive: Bool { + self == .spawning || self == .running || self == .waiting + } +} + +// MARK: - Store + +@Observable +class DashboardStore { + var projectName: String = "" + var worktrees: [Worktree] = [] + var connectionState: ConnectionState = .disconnected + + func refresh() async {} + func connect() async {} + func killAgent(_ agentId: String, in worktreeId: String) async {} + func restartAgent(_ agentId: String, in worktreeId: String) async {} + func mergeWorktree(_ worktreeId: String) async {} + func killWorktree(_ worktreeId: String) async {} + func createPullRequest(for worktreeId: String) async {} + + func worktree(by id: String) -> Worktree? { + worktrees.first { $0.id == id } + } +} + +// MARK: - Preview Helpers + +#if DEBUG +extension DashboardStore { + static var preview: DashboardStore { + let store = DashboardStore() + store.projectName = "my-project" + store.connectionState = .connected + store.worktrees = [ + Worktree( + id: "wt-abc123", + name: "auth-feature", + branch: "ppg/auth-feature", + path: ".worktrees/wt-abc123", + status: .running, + agents: [ + Agent(id: "ag-11111111", name: "claude-1", agentType: "claude", status: .running, prompt: "Implement auth", startedAt: .now.addingTimeInterval(-300), completedAt: nil, exitCode: nil, error: nil), + Agent(id: "ag-22222222", name: "claude-2", agentType: "claude", status: .completed, prompt: "Write tests", startedAt: .now.addingTimeInterval(-600), completedAt: .now.addingTimeInterval(-120), exitCode: 0, error: nil), + ], + diffStats: DiffStats(filesChanged: 12, insertions: 340, deletions: 45), + createdAt: .now.addingTimeInterval(-3600), + mergedAt: nil + ), + Worktree( + id: "wt-def456", + name: "fix-bug", + branch: "ppg/fix-bug", + path: ".worktrees/wt-def456", + status: .merged, + agents: [ + Agent(id: "ag-33333333", name: "codex-1", agentType: "codex", status: .completed, prompt: "Fix the login bug", startedAt: .now.addingTimeInterval(-7200), completedAt: .now.addingTimeInterval(-3600), exitCode: 0, error: nil), + ], + diffStats: DiffStats(filesChanged: 3, insertions: 28, deletions: 12), + createdAt: .now.addingTimeInterval(-86400), + mergedAt: .now.addingTimeInterval(-3600) + ), + ] + return store + } + + static var previewEmpty: DashboardStore { + let store = DashboardStore() + store.projectName = "new-project" + store.connectionState = .connected + return store + } + + static var previewDisconnected: DashboardStore { + let store = DashboardStore() + store.projectName = "my-project" + store.connectionState = .disconnected + return store + } +} +#endif diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift index 51e2b41..ebae98a 100644 --- a/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift @@ -5,6 +5,8 @@ struct AgentRow: View { var onKill: (() -> Void)? var onRestart: (() -> Void)? + @State private var confirmingKill = false + var body: some View { VStack(alignment: .leading, spacing: 6) { HStack { @@ -50,6 +52,14 @@ struct AgentRow: View { } } .padding(.vertical, 4) + .confirmationDialog("Kill Agent", isPresented: $confirmingKill) { + Button("Kill", role: .destructive) { + onKill?() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Kill agent \"\(agent.name)\"? This cannot be undone.") + } } // MARK: - Status Label @@ -72,7 +82,7 @@ struct AgentRow: View { HStack(spacing: 12) { if agent.status.isActive { Button { - onKill?() + confirmingKill = true } label: { Image(systemName: "stop.fill") .font(.caption) @@ -95,6 +105,7 @@ struct AgentRow: View { } } +#if DEBUG #Preview { List { AgentRow( @@ -123,3 +134,4 @@ struct AgentRow: View { } .listStyle(.insetGrouped) } +#endif diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift index 2eaea79..1b55bca 100644 --- a/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift @@ -65,8 +65,14 @@ struct DashboardView: View { await store.refresh() } .navigationDestination(for: String.self) { worktreeId in - if let worktree = store.worktrees.first(where: { $0.id == worktreeId }) { - WorktreeDetailView(worktree: worktree, store: store) + if let worktree = store.worktree(by: worktreeId) { + WorktreeDetailView(worktreeId: worktree.id, store: store) + } else { + ContentUnavailableView( + "Worktree Not Found", + systemImage: "questionmark.folder", + description: Text("This worktree may have been removed.") + ) } } } @@ -140,126 +146,7 @@ struct DashboardView: View { } } -// MARK: - Domain Models - -enum ConnectionState { - case disconnected - case connecting - case connected -} - -struct Worktree: Identifiable { - let id: String - let name: String - let branch: String - let path: String - let status: WorktreeStatus - let agents: [Agent] - let createdAt: Date - let mergedAt: Date? -} - -enum WorktreeStatus: String { - case spawning - case running - case merged - case cleaned - case merging - - var isTerminal: Bool { - self == .merged || self == .cleaned - } - - var label: String { rawValue.capitalized } - - var color: Color { - switch self { - case .spawning: .yellow - case .running: .green - case .merging: .orange - case .merged: .blue - case .cleaned: .secondary - } - } - - var icon: String { - switch self { - case .spawning: "hourglass" - case .running: "play.circle.fill" - case .merging: "arrow.triangle.merge" - case .merged: "checkmark.circle.fill" - case .cleaned: "archivebox" - } - } -} - -struct Agent: Identifiable { - let id: String - let name: String - let agentType: String - let status: AgentStatus - let prompt: String - let startedAt: Date - let completedAt: Date? - let exitCode: Int? - let error: String? -} - -enum AgentStatus: String, CaseIterable { - case spawning - case running - case waiting - case completed - case failed - case killed - case lost - - var label: String { rawValue.capitalized } - - var color: Color { - switch self { - case .running: .green - case .completed: .blue - case .failed: .red - case .killed: .orange - case .spawning: .yellow - case .waiting, .lost: .secondary - } - } - - var icon: String { - switch self { - case .spawning: "hourglass" - case .running: "play.circle.fill" - case .waiting: "pause.circle" - case .completed: "checkmark.circle.fill" - case .failed: "xmark.circle.fill" - case .killed: "stop.circle.fill" - case .lost: "questionmark.circle" - } - } - - var isActive: Bool { - self == .spawning || self == .running || self == .waiting - } -} - -// MARK: - Store Protocol - -@Observable -class DashboardStore { - var projectName: String = "" - var worktrees: [Worktree] = [] - var connectionState: ConnectionState = .disconnected - - func refresh() async {} - func connect() async {} - func killAgent(_ agentId: String, in worktreeId: String) async {} - func restartAgent(_ agentId: String, in worktreeId: String) async {} - func mergeWorktree(_ worktreeId: String) async {} - func killWorktree(_ worktreeId: String) async {} -} - +#if DEBUG #Preview("Connected with worktrees") { DashboardView(store: .preview) } @@ -271,55 +158,4 @@ class DashboardStore { #Preview("Disconnected") { DashboardView(store: .previewDisconnected) } - -// MARK: - Preview Helpers - -extension DashboardStore { - static var preview: DashboardStore { - let store = DashboardStore() - store.projectName = "my-project" - store.connectionState = .connected - store.worktrees = [ - Worktree( - id: "wt-abc123", - name: "auth-feature", - branch: "ppg/auth-feature", - path: ".worktrees/wt-abc123", - status: .running, - agents: [ - Agent(id: "ag-11111111", name: "claude-1", agentType: "claude", status: .running, prompt: "Implement auth", startedAt: .now.addingTimeInterval(-300), completedAt: nil, exitCode: nil, error: nil), - Agent(id: "ag-22222222", name: "claude-2", agentType: "claude", status: .completed, prompt: "Write tests", startedAt: .now.addingTimeInterval(-600), completedAt: .now.addingTimeInterval(-120), exitCode: 0, error: nil), - ], - createdAt: .now.addingTimeInterval(-3600), - mergedAt: nil - ), - Worktree( - id: "wt-def456", - name: "fix-bug", - branch: "ppg/fix-bug", - path: ".worktrees/wt-def456", - status: .merged, - agents: [ - Agent(id: "ag-33333333", name: "codex-1", agentType: "codex", status: .completed, prompt: "Fix the login bug", startedAt: .now.addingTimeInterval(-7200), completedAt: .now.addingTimeInterval(-3600), exitCode: 0, error: nil), - ], - createdAt: .now.addingTimeInterval(-86400), - mergedAt: .now.addingTimeInterval(-3600) - ), - ] - return store - } - - static var previewEmpty: DashboardStore { - let store = DashboardStore() - store.projectName = "new-project" - store.connectionState = .connected - return store - } - - static var previewDisconnected: DashboardStore { - let store = DashboardStore() - store.projectName = "my-project" - store.connectionState = .disconnected - return store - } -} +#endif diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift index 1bb9352..9494b39 100644 --- a/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift @@ -76,6 +76,7 @@ struct WorktreeCard: View { } } +#if DEBUG #Preview { List { WorktreeCard(worktree: Worktree( @@ -88,6 +89,7 @@ struct WorktreeCard: View { Agent(id: "ag-1", name: "claude-1", agentType: "claude", status: .running, prompt: "Implement auth", startedAt: .now, completedAt: nil, exitCode: nil, error: nil), Agent(id: "ag-2", name: "claude-2", agentType: "claude", status: .completed, prompt: "Write tests", startedAt: .now, completedAt: .now, exitCode: 0, error: nil), ], + diffStats: DiffStats(filesChanged: 8, insertions: 120, deletions: 15), createdAt: .now.addingTimeInterval(-3600), mergedAt: nil )) @@ -101,9 +103,11 @@ struct WorktreeCard: View { agents: [ Agent(id: "ag-3", name: "codex-1", agentType: "codex", status: .completed, prompt: "Fix bug", startedAt: .now, completedAt: .now, exitCode: 0, error: nil), ], + diffStats: DiffStats(filesChanged: 2, insertions: 10, deletions: 3), createdAt: .now.addingTimeInterval(-86400), mergedAt: .now.addingTimeInterval(-3600) )) } .listStyle(.insetGrouped) } +#endif diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift index 5c53a87..eda2af9 100644 --- a/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift @@ -1,42 +1,57 @@ import SwiftUI struct WorktreeDetailView: View { - let worktree: Worktree + let worktreeId: String @Bindable var store: DashboardStore @State private var confirmingMerge = false @State private var confirmingKill = false + private var worktree: Worktree? { + store.worktree(by: worktreeId) + } + var body: some View { - List { - infoSection - agentsSection - actionsSection - } - .listStyle(.insetGrouped) - .navigationTitle(worktree.name) - .navigationBarTitleDisplayMode(.large) - .confirmationDialog("Merge Worktree", isPresented: $confirmingMerge) { - Button("Squash Merge") { - Task { await store.mergeWorktree(worktree.id) } - } - Button("Cancel", role: .cancel) {} - } message: { - Text("Merge \"\(worktree.name)\" back to the base branch?") - } - .confirmationDialog("Kill Worktree", isPresented: $confirmingKill) { - Button("Kill All Agents", role: .destructive) { - Task { await store.killWorktree(worktree.id) } + Group { + if let worktree { + List { + infoSection(worktree) + diffStatsSection(worktree) + agentsSection(worktree) + actionsSection(worktree) + } + .listStyle(.insetGrouped) + .navigationTitle(worktree.name) + .navigationBarTitleDisplayMode(.large) + .confirmationDialog("Merge Worktree", isPresented: $confirmingMerge) { + Button("Squash Merge") { + Task { await store.mergeWorktree(worktreeId) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Merge \"\(worktree.name)\" back to the base branch?") + } + .confirmationDialog("Kill Worktree", isPresented: $confirmingKill) { + Button("Kill All Agents", role: .destructive) { + Task { await store.killWorktree(worktreeId) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Kill all agents in \"\(worktree.name)\"? This cannot be undone.") + } + } else { + ContentUnavailableView( + "Worktree Not Found", + systemImage: "questionmark.folder", + description: Text("This worktree may have been removed.") + ) } - Button("Cancel", role: .cancel) {} - } message: { - Text("Kill all agents in \"\(worktree.name)\"? This cannot be undone.") } } // MARK: - Info Section - private var infoSection: some View { + private func infoSection(_ worktree: Worktree) -> some View { Section { LabeledContent("Status") { HStack(spacing: 4) { @@ -72,9 +87,34 @@ struct WorktreeDetailView: View { } } + // MARK: - Diff Stats Section + + @ViewBuilder + private func diffStatsSection(_ worktree: Worktree) -> some View { + if let stats = worktree.diffStats { + Section { + LabeledContent("Files Changed") { + Text("\(stats.filesChanged)") + } + + LabeledContent("Insertions") { + Text("+\(stats.insertions)") + .foregroundStyle(.green) + } + + LabeledContent("Deletions") { + Text("-\(stats.deletions)") + .foregroundStyle(.red) + } + } header: { + Text("Changes") + } + } + } + // MARK: - Agents Section - private var agentsSection: some View { + private func agentsSection(_ worktree: Worktree) -> some View { Section { if worktree.agents.isEmpty { Text("No agents") @@ -84,10 +124,10 @@ struct WorktreeDetailView: View { AgentRow( agent: agent, onKill: { - Task { await store.killAgent(agent.id, in: worktree.id) } + Task { await store.killAgent(agent.id, in: worktreeId) } }, onRestart: { - Task { await store.restartAgent(agent.id, in: worktree.id) } + Task { await store.restartAgent(agent.id, in: worktreeId) } } ) } @@ -96,7 +136,7 @@ struct WorktreeDetailView: View { HStack { Text("Agents") Spacer() - Text(agentSummary) + Text(agentSummary(worktree)) .font(.caption) .foregroundStyle(.secondary) } @@ -105,7 +145,7 @@ struct WorktreeDetailView: View { // MARK: - Actions Section - private var actionsSection: some View { + private func actionsSection(_ worktree: Worktree) -> some View { Section { if worktree.status == .running { Button { @@ -122,7 +162,8 @@ struct WorktreeDetailView: View { } Button { - // PR creation — will be wired to store action + // TODO: Wire to store.createPullRequest(for:) + Task { await store.createPullRequest(for: worktreeId) } } label: { Label("Create Pull Request", systemImage: "arrow.triangle.pull") } @@ -134,7 +175,7 @@ struct WorktreeDetailView: View { // MARK: - Helpers - private var agentSummary: String { + private func agentSummary(_ worktree: Worktree) -> String { let active = worktree.agents.filter { $0.status.isActive }.count let total = worktree.agents.count if active > 0 { @@ -144,24 +185,13 @@ struct WorktreeDetailView: View { } } +#if DEBUG #Preview { NavigationStack { WorktreeDetailView( - worktree: Worktree( - id: "wt-abc123", - name: "auth-feature", - branch: "ppg/auth-feature", - path: ".worktrees/wt-abc123", - status: .running, - agents: [ - Agent(id: "ag-11111111", name: "claude-1", agentType: "claude", status: .running, prompt: "Implement OAuth2 authentication flow with JWT tokens", startedAt: .now.addingTimeInterval(-300), completedAt: nil, exitCode: nil, error: nil), - Agent(id: "ag-22222222", name: "claude-2", agentType: "claude", status: .completed, prompt: "Write integration tests for auth", startedAt: .now.addingTimeInterval(-600), completedAt: .now.addingTimeInterval(-120), exitCode: 0, error: nil), - Agent(id: "ag-33333333", name: "codex-1", agentType: "codex", status: .failed, prompt: "Set up auth middleware", startedAt: .now.addingTimeInterval(-500), completedAt: .now.addingTimeInterval(-200), exitCode: 1, error: "Process exited with code 1"), - ], - createdAt: .now.addingTimeInterval(-3600), - mergedAt: nil - ), + worktreeId: "wt-abc123", store: .preview ) } } +#endif From 8001339005223c21b094162cdedbe1302a70c114 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:36:10 -0600 Subject: [PATCH 3/3] fix: harden dashboard store/actions and repair typecheck --- ios/PPGMobile/PPGMobile/Models/DashboardModels.swift | 4 +++- .../PPGMobile/Views/Dashboard/AgentRow.swift | 12 +++++++----- src/commands/spawn.test.ts | 5 +++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift b/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift index 9163d35..af65393 100644 --- a/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift +++ b/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift @@ -117,8 +117,9 @@ enum AgentStatus: String, CaseIterable { // MARK: - Store +@MainActor @Observable -class DashboardStore { +final class DashboardStore { var projectName: String = "" var worktrees: [Worktree] = [] var connectionState: ConnectionState = .disconnected @@ -139,6 +140,7 @@ class DashboardStore { // MARK: - Preview Helpers #if DEBUG +@MainActor extension DashboardStore { static var preview: DashboardStore { let store = DashboardStore() diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift index ebae98a..ed883d8 100644 --- a/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift @@ -53,8 +53,10 @@ struct AgentRow: View { } .padding(.vertical, 4) .confirmationDialog("Kill Agent", isPresented: $confirmingKill) { - Button("Kill", role: .destructive) { - onKill?() + if let onKill { + Button("Kill", role: .destructive) { + onKill() + } } Button("Cancel", role: .cancel) {} } message: { @@ -80,7 +82,7 @@ struct AgentRow: View { @ViewBuilder private var actionButtons: some View { HStack(spacing: 12) { - if agent.status.isActive { + if agent.status.isActive, onKill != nil { Button { confirmingKill = true } label: { @@ -91,9 +93,9 @@ struct AgentRow: View { .buttonStyle(.borderless) } - if agent.status == .failed || agent.status == .killed { + if (agent.status == .failed || agent.status == .killed), let onRestart { Button { - onRestart?() + onRestart() } label: { Image(systemName: "arrow.counterclockwise") .font(.caption) diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..541d560 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', @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') { } describe('spawnCommand', () => { - let manifestState = createManifest(); + let manifestState: Manifest = createManifest(); let nextAgent = 1; let nextSession = 1;