From 71d33e355c652ce6fb5d1f5598ba9c12e78e407b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 00:14:36 -0500 Subject: [PATCH] feat(watcher): add idle detection with auto-pause/resume When user is idle beyond IdleTimeoutMs, automatically pauses the active task via the API. When user returns, resumes the task if it's still paused. Co-Authored-By: Claude Opus 4.6 --- WindowWatcher/Worker.cs | 98 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/WindowWatcher/Worker.cs b/WindowWatcher/Worker.cs index a46ea81..b8d99df 100644 --- a/WindowWatcher/Worker.cs +++ b/WindowWatcher/Worker.cs @@ -15,12 +15,15 @@ public class Worker( private string _lastAppName = string.Empty; private string _lastWindowTitle = string.Empty; private DateTime _lastChangeTime = DateTime.MinValue; + private bool _isIdle; + private int? _pausedTaskId; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var config = options.Value; - logger.LogInformation("WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms", - config.PollIntervalMs, config.DebounceMs); + logger.LogInformation( + "WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms", + config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs); while (!stoppingToken.IsCancellationRequested) { @@ -67,6 +70,21 @@ public class Worker( _lastWindowTitle = windowTitle; _lastChangeTime = now; } + + // Idle detection + var idleTime = NativeMethods.GetIdleTime(); + if (!_isIdle && idleTime.TotalMilliseconds >= config.IdleTimeoutMs) + { + _isIdle = true; + logger.LogInformation("User idle for {IdleTime}, pausing active task", idleTime); + await PauseActiveTaskAsync(ct: stoppingToken); + } + else if (_isIdle && idleTime.TotalMilliseconds < config.IdleTimeoutMs) + { + _isIdle = false; + logger.LogInformation("User returned from idle"); + await ResumeIdlePausedTaskAsync(ct: stoppingToken); + } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -108,4 +126,80 @@ public class Worker( logger.LogWarning(ex, "Failed to report context event to API"); } } + + private async Task PauseActiveTaskAsync(CancellationToken ct) + { + try + { + var client = httpClientFactory.CreateClient("TaskTrackerApi"); + + // Get the active task + var response = await client.GetFromJsonAsync>( + "/api/tasks/active", ct); + + if (response?.Data is null) + { + logger.LogDebug("No active task to pause"); + return; + } + + _pausedTaskId = response.Data.Id; + + // Pause it + var pauseResponse = await client.PutAsJsonAsync( + $"/api/tasks/{_pausedTaskId}/pause", + new { note = "Auto-paused: idle timeout" }, ct); + + if (pauseResponse.IsSuccessStatusCode) + logger.LogInformation("Auto-paused task {TaskId}", _pausedTaskId); + else + logger.LogWarning("Failed to pause task {TaskId}: {Status}", _pausedTaskId, pauseResponse.StatusCode); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to pause active task on idle"); + } + } + + private async Task ResumeIdlePausedTaskAsync(CancellationToken ct) + { + if (_pausedTaskId is null) + return; + + var taskId = _pausedTaskId.Value; + _pausedTaskId = null; + + try + { + var client = httpClientFactory.CreateClient("TaskTrackerApi"); + + // Check the task is still paused (user may have manually switched tasks) + var response = await client.GetFromJsonAsync>( + $"/api/tasks/{taskId}", ct); + + if (response?.Data is null || response.Data.Status != "Paused") + { + logger.LogDebug("Task {TaskId} is no longer paused, skipping auto-resume", taskId); + return; + } + + // Resume it + var resumeResponse = await client.PutAsJsonAsync( + $"/api/tasks/{taskId}/resume", + new { note = "Auto-resumed: user returned" }, ct); + + if (resumeResponse.IsSuccessStatusCode) + logger.LogInformation("Auto-resumed task {TaskId}", taskId); + else + logger.LogWarning("Failed to resume task {TaskId}: {Status}", taskId, resumeResponse.StatusCode); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to resume task {TaskId} after idle", taskId); + } + } } + +internal record ActiveTaskDto(int Id, string Status); + +internal record ApiResponse(bool Success, T? Data, string? Error);