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
14 changes: 7 additions & 7 deletions .agent-os/specs/2025-07-22-phase1-completion/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ These are the tasks to be completed for the spec detailed in @.agent-os/specs/20

## Tasks

- [ ] 1. **Implement Pause/Resume UI Controls**
- [ ] 1.1 Write tests for pause/resume button components and state management
- [ ] 1.2 Add pause/resume buttons to main automation control panel in ClickItViewModel
- [ ] 1.3 Integrate pause/resume functionality with ElapsedTimeManager and ClickCoordinator
- [ ] 1.4 Update visual feedback overlay to reflect pause/resume states
- [ ] 1.5 Ensure session statistics preservation during pause state
- [ ] 1.6 Verify all tests pass and UI responds correctly
- [x] 1. **Implement Pause/Resume UI Controls**
- [x] 1.1 Write tests for pause/resume button components and state management
- [x] 1.2 Add pause/resume buttons to main automation control panel in ClickItViewModel
- [x] 1.3 Integrate pause/resume functionality with ElapsedTimeManager and ClickCoordinator
- [x] 1.4 Update visual feedback overlay to reflect pause/resume states
- [x] 1.5 Ensure session statistics preservation during pause state
- [x] 1.6 Verify all tests pass and UI responds correctly

- [ ] 2. **Build Enhanced Preset Management System**
- [ ] 2.1 Write tests for PresetManager and PresetConfiguration data structures
Expand Down
81 changes: 62 additions & 19 deletions Sources/ClickIt/UI/Components/StatusHeaderCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,28 +66,71 @@ struct StatusHeaderCard: View {
)
}

// Primary Action Button
Button(action: {
if viewModel.isRunning {
viewModel.stopAutomation()
} else {
viewModel.startAutomation()
}
}) {
HStack(spacing: 8) {
Image(systemName: viewModel.isRunning ? "stop.fill" : "play.fill")
.font(.system(size: 16, weight: .medium))
// Control Buttons
if viewModel.isRunning || viewModel.isPaused {
// Running/Paused state: Show Pause/Resume and Stop buttons
HStack(spacing: 12) {
// Pause/Resume Button
Button(action: {
if viewModel.canPause {
viewModel.pauseAutomation()
} else if viewModel.canResume {
viewModel.resumeAutomation()
}
}) {
HStack(spacing: 6) {
Image(systemName: viewModel.isPaused ? "play.fill" : "pause.fill")
.font(.system(size: 14, weight: .medium))

Text(viewModel.isPaused ? "Resume" : "Pause")
.font(.subheadline)
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.bordered)
.disabled(!viewModel.canPause && !viewModel.canResume)
.tint(viewModel.isPaused ? .green : .orange)

Text(viewModel.isRunning ? "Stop Automation" : "Start Automation")
.font(.headline)
.fontWeight(.medium)
// Stop Button
Button(action: {
viewModel.stopAutomation()
}) {
HStack(spacing: 6) {
Image(systemName: "stop.fill")
.font(.system(size: 14, weight: .medium))

Text("Stop")
.font(.subheadline)
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
} else {
// Ready state: Show Start button
Button(action: {
viewModel.startAutomation()
}) {
HStack(spacing: 8) {
Image(systemName: "play.fill")
.font(.system(size: 16, weight: .medium))

Text("Start Automation")
.font(.headline)
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.frame(maxWidth: .infinity)
.frame(height: 44)
.buttonStyle(.borderedProminent)
.disabled(!viewModel.canStartAutomation)
.tint(.green)
}
.buttonStyle(.borderedProminent)
.disabled(!viewModel.canStartAutomation && !viewModel.isRunning)
.tint(viewModel.isRunning ? .red : .green)
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
Expand Down
91 changes: 90 additions & 1 deletion Sources/ClickIt/UI/ViewModels/ClickItViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class ClickItViewModel: ObservableObject {
// MARK: - Published Properties
@Published var targetPoint: CGPoint?
@Published var isRunning = false
@Published var isPaused = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Having both isRunning and isPaused as separate @Published properties can lead to inconsistent states. Consider using the appStatus enum as the single source of truth and derive isRunning and isPaused as computed properties1.

Style Guide References

Footnotes

  1. Using a single source of truth for state management improves consistency and reduces potential errors. (link)

@Published var appStatus: AppStatus = .ready

// Configuration Properties
Expand Down Expand Up @@ -69,6 +70,14 @@ class ClickItViewModel: ObservableObject {
return total >= 1 && total <= 3600 // 1 second to 60 minutes
}

var canPause: Bool {
isRunning && !isPaused
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Given the state management, the !isPaused check is redundant since isRunning implies !isPaused. Consider simplifying to just isRunning.

    var canPause: Bool {
        isRunning
    }

}

var canResume: Bool {
isPaused && !isRunning
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to canPause, the !isRunning check is redundant. When isPaused is true, isRunning is always false. Simplify to isPaused.

    var canResume: Bool {
        isPaused
    }

}

// MARK: - Dependencies
private let clickCoordinator = ClickCoordinator.shared

Expand Down Expand Up @@ -151,9 +160,83 @@ class ClickItViewModel: ObservableObject {
clickCoordinator.stopAutomation()
cancelTimer() // Also cancel any active timer
isRunning = false
isPaused = false
appStatus = .ready
}

func pauseAutomation() {
guard isRunning && !isPaused else { return }

clickCoordinator.stopAutomation()
ElapsedTimeManager.shared.pauseTracking()

// Update visual feedback to show paused state (dimmed)
if showVisualFeedback, let point = targetPoint {
VisualFeedbackOverlay.shared.updateOverlay(at: point, isActive: false)
}

isRunning = false
isPaused = true
appStatus = .paused
}

func resumeAutomation() {
guard isPaused && !isRunning else { return }

// Resume elapsed time tracking
ElapsedTimeManager.shared.resumeTracking()

// Restart automation with current configuration
guard let point = targetPoint else { return }

let config = AutomationConfiguration(
location: point,
clickType: clickType,
clickInterval: Double(totalMilliseconds) / 1000.0,
targetApplication: nil,
maxClicks: durationMode == .clickCount ? maxClicks : nil,
maxDuration: durationMode == .timeLimit ? durationSeconds : nil,
stopOnError: stopOnError,
randomizeLocation: randomizeLocation,
locationVariance: CGFloat(randomizeLocation ? locationVariance : 0),
showVisualFeedback: showVisualFeedback,
useDynamicMouseTracking: false
)
Comment on lines +192 to +204
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This AutomationConfiguration block is duplicated in startAutomationForTesting(). Extract this logic into a private helper method to avoid code duplication and improve maintainability1.

Style Guide References

Footnotes

  1. Extracting duplicated code into a helper method improves maintainability and reduces the risk of errors. (link)


clickCoordinator.startAutomation(with: config)
isRunning = true
isPaused = false
appStatus = .running

// Update visual feedback to show active state
if showVisualFeedback {
VisualFeedbackOverlay.shared.updateOverlay(at: point, isActive: true)
}
}

// MARK: - Testing Methods
func startAutomationForTesting() {
guard let point = targetPoint else { return }

let config = AutomationConfiguration(
location: point,
clickType: clickType,
clickInterval: Double(totalMilliseconds) / 1000.0,
targetApplication: nil,
maxClicks: durationMode == .clickCount ? maxClicks : nil,
maxDuration: durationMode == .timeLimit ? durationSeconds : nil,
stopOnError: stopOnError,
randomizeLocation: randomizeLocation,
locationVariance: CGFloat(randomizeLocation ? locationVariance : 0),
showVisualFeedback: false, // Disable visual feedback for tests
useDynamicMouseTracking: false
)

clickCoordinator.startAutomation(with: config)
isRunning = true
appStatus = .running
}

func resetConfiguration() {
intervalHours = 0
intervalMinutes = 0
Expand Down Expand Up @@ -189,9 +272,10 @@ class ClickItViewModel: ObservableObject {
guard let self = self else { return }

// Sync ViewModel state with ClickCoordinator state
if !isActive && self.isRunning {
if !isActive && (self.isRunning || self.isPaused) {
print("ClickItViewModel: Automation stopped externally (e.g., DELETE key), updating UI state")
self.isRunning = false
self.isPaused = false
self.appStatus = .ready
// Also cancel any active timer when automation stops
self.cancelTimer()
Expand Down Expand Up @@ -349,6 +433,7 @@ enum TimerMode {
enum AppStatus {
case ready
case running
case paused
case error(String)

var displayText: String {
Expand All @@ -357,6 +442,8 @@ enum AppStatus {
return "Ready"
case .running:
return "Running"
case .paused:
return "Paused"
case .error(let message):
return "Error: \(message)"
}
Expand All @@ -368,6 +455,8 @@ enum AppStatus {
return .green
case .running:
return .blue
case .paused:
return .orange
case .error:
return .red
}
Expand Down
Loading
Loading