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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,12 +15,15 @@ public class Worker(
|
|||||||
private string _lastAppName = string.Empty;
|
private string _lastAppName = string.Empty;
|
||||||
private string _lastWindowTitle = string.Empty;
|
private string _lastWindowTitle = string.Empty;
|
||||||
private DateTime _lastChangeTime = DateTime.MinValue;
|
private DateTime _lastChangeTime = DateTime.MinValue;
|
||||||
|
private bool _isIdle;
|
||||||
|
private int? _pausedTaskId;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
var config = options.Value;
|
var config = options.Value;
|
||||||
logger.LogInformation("WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms",
|
logger.LogInformation(
|
||||||
config.PollIntervalMs, config.DebounceMs);
|
"WindowWatcher started. Polling every {Interval}ms, debounce {Debounce}ms, idle timeout {IdleTimeout}ms",
|
||||||
|
config.PollIntervalMs, config.DebounceMs, config.IdleTimeoutMs);
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -67,6 +70,21 @@ public class Worker(
|
|||||||
_lastWindowTitle = windowTitle;
|
_lastWindowTitle = windowTitle;
|
||||||
_lastChangeTime = now;
|
_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)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -108,4 +126,80 @@ public class Worker(
|
|||||||
logger.LogWarning(ex, "Failed to report context event to API");
|
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<ApiResponse<ActiveTaskDto>>(
|
||||||
|
"/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<ApiResponse<ActiveTaskDto>>(
|
||||||
|
$"/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<T>(bool Success, T? Data, string? Error);
|
||||||
|
|||||||
Reference in New Issue
Block a user