diff --git a/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift b/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift new file mode 100644 index 0000000..af65393 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Models/DashboardModels.swift @@ -0,0 +1,195 @@ +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 + +@MainActor +@Observable +final 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 +@MainActor +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 new file mode 100644 index 0000000..ed883d8 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/AgentRow.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct AgentRow: View { + let agent: Agent + var onKill: (() -> Void)? + var onRestart: (() -> Void)? + + @State private var confirmingKill = false + + 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) + .confirmationDialog("Kill Agent", isPresented: $confirmingKill) { + if let onKill { + Button("Kill", role: .destructive) { + onKill() + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Kill agent \"\(agent.name)\"? This cannot be undone.") + } + } + + // 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, onKill != nil { + Button { + confirmingKill = true + } label: { + Image(systemName: "stop.fill") + .font(.caption) + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + } + + if (agent.status == .failed || agent.status == .killed), let onRestart { + Button { + onRestart() + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.caption) + .foregroundStyle(.blue) + } + .buttonStyle(.borderless) + } + } + } +} + +#if DEBUG +#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) +} +#endif diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..1b55bca --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/DashboardView.swift @@ -0,0 +1,161 @@ +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.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.") + ) + } + } + } + + // 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" + } + } +} + +#if DEBUG +#Preview("Connected with worktrees") { + DashboardView(store: .preview) +} + +#Preview("Empty state") { + DashboardView(store: .previewEmpty) +} + +#Preview("Disconnected") { + DashboardView(store: .previewDisconnected) +} +#endif diff --git a/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift new file mode 100644 index 0000000..9494b39 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeCard.swift @@ -0,0 +1,113 @@ +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 } + } +} + +#if DEBUG +#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), + ], + diffStats: DiffStats(filesChanged: 8, insertions: 120, deletions: 15), + 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), + ], + 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 new file mode 100644 index 0000000..eda2af9 --- /dev/null +++ b/ios/PPGMobile/PPGMobile/Views/Dashboard/WorktreeDetailView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +struct WorktreeDetailView: View { + 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 { + 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.") + ) + } + } + } + + // MARK: - Info Section + + private func infoSection(_ worktree: Worktree) -> 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: - 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 func agentsSection(_ worktree: Worktree) -> 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: worktreeId) } + }, + onRestart: { + Task { await store.restartAgent(agent.id, in: worktreeId) } + } + ) + } + } + } header: { + HStack { + Text("Agents") + Spacer() + Text(agentSummary(worktree)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Actions Section + + private func actionsSection(_ worktree: Worktree) -> 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 { + // TODO: Wire to store.createPullRequest(for:) + Task { await store.createPullRequest(for: worktreeId) } + } label: { + Label("Create Pull Request", systemImage: "arrow.triangle.pull") + } + .disabled(worktree.status != .running && worktree.status != .merged) + } header: { + Text("Actions") + } + } + + // MARK: - Helpers + + private func agentSummary(_ worktree: Worktree) -> 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" + } +} + +#if DEBUG +#Preview { + NavigationStack { + WorktreeDetailView( + worktreeId: "wt-abc123", + store: .preview + ) + } +} +#endif 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;