From 67ce3e98e8810b745d27ebccbe748853831f0bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Fri, 27 Mar 2026 18:01:35 +0100 Subject: [PATCH 01/11] tm-user-idle-gib Add ActivityLevel shared type and reportActivity API method Add [RequireQualifiedAccess] ActivityLevel DU (Active | Idle | DeepIdle) to src/Shared/Types.fs. Add reportActivity: ActivityLevel -> Async to IWorktreeApi record. Stub implementations in readOnlyApi and worktreeApi. --- docs/spec/user-idle-detection.md | 79 ++++++++++++++++++++++++++++++++ src/Server/WorktreeApi.fs | 6 ++- src/Shared/Types.fs | 9 +++- 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 docs/spec/user-idle-detection.md diff --git a/docs/spec/user-idle-detection.md b/docs/spec/user-idle-detection.md new file mode 100644 index 0000000..4cd61be --- /dev/null +++ b/docs/spec/user-idle-detection.md @@ -0,0 +1,79 @@ +# User Idle Detection — Adaptive Refresh Cadence + +## Goals + +- Detect when the user is actively using the dashboard vs. away +- Poll aggressively when active (5-10s for local/network tasks) for a responsive feel +- Ease off when idle (current baseline) and conserve resources when deeply idle (1-10 min intervals) +- Use coding tool activity in monitored worktrees as an additional "user is active" signal + +## Expected Behavior + +### Activity States + +Three states derived from two signals (dashboard interaction + coding tool messages): + +| State | Condition | +|-------|-----------| +| Active | Dashboard interaction within last 60s, OR any coding tool user message within last 5 min | +| Idle | No dashboard interaction for 60s AND no recent coding tool messages | +| Deep Idle | No dashboard interaction for 15 min AND no recent coding tool messages | + +Dashboard interaction = `mousemove`, `keydown`, `click`, `scroll` on the document. Tracked coarse-grained (throttled to 1 dispatch per 5s). App runs as a PWA — no tab-switching concerns; when minimized, mouse events stop and the 60s timer kicks in naturally. + +### Refresh Intervals + +| Category | Active | Idle (current baseline) | Deep Idle | +|----------|--------|------------------------|-----------| +| Client poll | 1s | 1s | 15s | +| Git | 5s | 15s | 60s | +| CodingTool | 5s | 15s | 60s | +| Beads | 30s | 60s | 240s | +| WorktreeList | 10s | 15s | 60s | +| PR | 10s | 120s | 600s (10 min) | +| Fetch | 10s | 120s | 600s (10 min) | + +### Sync Polling + +SyncTick (2s interval when a sync is running) is unaffected by activity level. Syncs are user-initiated operations — always show progress regardless of idle state. + +### Wake-Up Behavior + +When transitioning from Idle/DeepIdle → Active, the client immediately dispatches a Tick (fetches fresh data) and reports the transition to the server. The user sees fresh data within 1s of touching the dashboard. + +## Technical Approach + +### Shared Types (`src/Shared/Types.fs`) + +New `ActivityLevel` DU: `Active | Idle | DeepIdle`. New `reportActivity: ActivityLevel -> Async` method on `IWorktreeApi`. + +### Client-Side (`src/Client/App.fs`) + +Elmish subscription registers DOM event listeners (mousemove, keydown, click, scroll). Throttled to dispatch `UserActivity` at most once per 5s — the throttle uses a mutable timestamp inside the subscription closure (Elmish's designated impure boundary, same pattern as `setInterval`). The Model stays fully immutable with `LastActivityTime: float` and `ActivityLevel: ActivityLevel`. + +`computeActivityLevel` is a pure function: compares `Date.now() - lastActivity` against 60s/15min thresholds. + +`pollingSubscription` includes activity level in its key so Elmish tears down and recreates the interval on transitions. Active/Idle = 1s, DeepIdle = 15s. + +On activity level transitions, `reportActivity` is called to inform the server. + +### Server-Side (`src/Server/RefreshScheduler.fs`) + +`DashboardState` gets a `ClientActivity: ActivityLevel` field, defaulting to `Idle`. New `ReportClientActivity` message updates it. `intervalOf` becomes a function of `(ActivityLevel * RefreshTask)` with explicit intervals per combination (no multiplier math). + +`deadlineOf` passes activity through. The scheduler loop already reads state each iteration, so it naturally adapts. + +`effectiveActivity` combines client report with coding tool data: if any worktree has a user message within 5 min, override to Active. + +### Server API (`src/Server/WorktreeApi.fs`) + +New `reportActivity` endpoint posts `ReportClientActivity` to the scheduler agent. + +## Decisions + +- No WebSocket/SignalR — tune existing polling intervals +- No per-worktree idle tracking — one global activity level +- No sync-running override — activity level is activity level, least code +- No multi-tab support — PWA, single tab assumed +- Mutable state confined to subscription throttle closure only +- `ActivityLevel` uses `[]` — its `Idle` case would shadow `CodingToolStatus.Idle` diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index 7946c23..76fb6c9 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -32,7 +32,8 @@ let readOnlyApi getBranches = fun _ -> async { return [] } createWorktree = fun _ -> async { return Error $"Create is not available in {modeName}" } openNewTab = fun _ -> async { return Error $"Session management is not available in {modeName}" } - launchAction = fun _ -> async { return Error $"Session management is not available in {modeName}" } } + launchAction = fun _ -> async { return Error $"Session management is not available in {modeName}" } + reportActivity = fun _ -> async { return () } } let private assembleFromState (activeSessions: Set) @@ -491,4 +492,5 @@ let worktreeApi | action -> CodingToolStatus.actionPrompt provider action let command = CodingToolStatus.buildInteractiveCommand provider prompt return! SessionManager.launchAction sessionAgent req.Path command - }) } + }) + reportActivity = fun _ -> async { return () } } diff --git a/src/Shared/Types.fs b/src/Shared/Types.fs index b24d081..587d9fc 100644 --- a/src/Shared/Types.fs +++ b/src/Shared/Types.fs @@ -41,6 +41,12 @@ type CodingToolProvider = | Claude | Copilot +[] +type ActivityLevel = + | Active + | Idle + | DeepIdle + type BuildStatus = | Building | Succeeded @@ -182,4 +188,5 @@ type IWorktreeApi = getBranches: string -> Async createWorktree: CreateWorktreeRequest -> Async> openNewTab: WorktreePath -> Async> - launchAction: ActionRequest -> Async> } + launchAction: ActionRequest -> Async> + reportActivity: ActivityLevel -> Async } From 40631a105c7f9fa7423d22a5ce0bfc7bed62632a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Fri, 27 Mar 2026 18:18:56 +0100 Subject: [PATCH 02/11] tm-user-idle-cc1 Client-side idle detection and dynamic polling Add ActivityLevel tracking to client Model with LastActivityTime. Add UserActivity message with throttled activity subscription. Add computeActivityLevel pure function (60s Idle, 15min DeepIdle). Update Tick handler with activity transition detection. Update pollingSubscription to use activity-level-based intervals. Fix test files to pass ActivityLevel parameter. --- src/Client/App.fs | 89 +++++++++++++++++++++++++++++--- src/Server/RefreshScheduler.fs | 81 ++++++++++++++++++++++------- src/Server/WorktreeApi.fs | 2 +- src/Tests/ConfirmModalTests.fs | 4 +- src/Tests/CreateWorktreeTests.fs | 4 +- src/Tests/SchedulerTests.fs | 24 ++++----- 6 files changed, 164 insertions(+), 40 deletions(-) diff --git a/src/Client/App.fs b/src/Client/App.fs index 5ee8a9d..db26a83 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -29,7 +29,9 @@ type Model = DeletedPaths: Set DeployBranch: string option SystemMetrics: SystemMetrics option - ActionCooldowns: Set } + ActionCooldowns: Set + LastActivityTime: float + ActivityLevel: ActivityLevel } type Msg = | DataLoaded of DashboardResponse @@ -61,6 +63,7 @@ type Msg = | LaunchActionResult of Result | ClearActionCooldown of WorktreePath | ModalMsg of CreateWorktreeModal.Msg + | UserActivity let worktreeApi = Remoting.createApi () @@ -98,7 +101,9 @@ let init () = DeletedPaths = Set.empty DeployBranch = None SystemMetrics = None - ActionCooldowns = Set.empty }, + ActionCooldowns = Set.empty + LastActivityTime = Fable.Core.JS.Constructors.Date.now () + ActivityLevel = ActivityLevel.Active }, Cmd.batch [ fetchWorktrees (); fetchSyncStatus () ] let rng = System.Random() @@ -156,6 +161,16 @@ let keyBinding (focused: FocusTarget) (key: string) (model: Model) : Msg option | RepoHeader repoId, "+" -> Some (ModalMsg (CreateWorktreeModal.OpenCreateWorktree repoId)) | _ -> None +let idleThresholdMs = 60_000.0 +let deepIdleThresholdMs = 900_000.0 + +let computeActivityLevel (lastActivityTime: float) (now: float) = + let elapsed = now - lastActivityTime + + if elapsed < idleThresholdMs then ActivityLevel.Active + elif elapsed < deepIdleThresholdMs then ActivityLevel.Idle + else ActivityLevel.DeepIdle + let update msg model = match msg with | DataLoaded response -> @@ -241,7 +256,35 @@ let update msg model = model, Cmd.OfAsync.attempt worktreeApi.openEditor path (fun _ -> Tick) | Tick -> - model, Cmd.batch [ fetchWorktrees (); fetchSyncStatus () ] + let now = Fable.Core.JS.Constructors.Date.now () + let newLevel = computeActivityLevel model.LastActivityTime now + + let transitionCmd = + if newLevel <> model.ActivityLevel then + Cmd.OfAsync.attempt worktreeApi.reportActivity newLevel (fun _ -> Tick) + else + Cmd.none + + { model with ActivityLevel = newLevel }, + Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); transitionCmd ] + + | UserActivity -> + let now = Fable.Core.JS.Constructors.Date.now () + let wasActive = model.ActivityLevel = ActivityLevel.Active + + let wakeUpCmd = + if not wasActive then + Cmd.batch [ + Cmd.ofMsg Tick + Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> Tick) + ] + else + Cmd.none + + { model with + LastActivityTime = now + ActivityLevel = ActivityLevel.Active }, + wakeUpCmd | StartSync (path, key) -> let syntheticEvent = @@ -423,9 +466,20 @@ let update msg model = | None -> model, Cmd.none let pollingSubscription (model: Model) : Sub = + let pollingIntervalMs = + match model.ActivityLevel with + | ActivityLevel.Active | ActivityLevel.Idle -> 1000 + | ActivityLevel.DeepIdle -> 15000 + + let activityLevelKey = + match model.ActivityLevel with + | ActivityLevel.Active -> "active" + | ActivityLevel.Idle -> "idle" + | ActivityLevel.DeepIdle -> "deep-idle" + let worktreePolling (dispatch: Dispatch) = let intervalId = - Fable.Core.JS.setInterval (fun () -> dispatch Tick) 1000 + Fable.Core.JS.setInterval (fun () -> dispatch Tick) pollingIntervalMs { new System.IDisposable with member _.Dispose() = Fable.Core.JS.clearInterval intervalId } @@ -435,11 +489,32 @@ let pollingSubscription (model: Model) : Sub = { new System.IDisposable with member _.Dispose() = Fable.Core.JS.clearInterval intervalId } + let activityDetection (dispatch: Dispatch) = + let mutable lastDispatchTime = Fable.Core.JS.Constructors.Date.now () + let throttleMs = 5000.0 + + let handler = + fun (_: Browser.Types.Event) -> + let now = Fable.Core.JS.Constructors.Date.now () + if now - lastDispatchTime >= throttleMs then + lastDispatchTime <- now + dispatch UserActivity + + let events = [| "mousemove"; "keydown"; "click"; "scroll" |] + events |> Array.iter (fun evt -> Dom.document.addEventListener (evt, handler)) + + { new System.IDisposable with + member _.Dispose() = + events |> Array.iter (fun evt -> Dom.document.removeEventListener (evt, handler)) } + + let subs = + [ [ "polling"; activityLevelKey ], worktreePolling + [ "activity" ], activityDetection ] + if hasSyncRunning model.BranchEvents then - [ [ "polling" ], worktreePolling - [ "sync-polling" ], syncPolling ] + ([ "sync-polling" ], syncPolling) :: subs else - [ [ "polling" ], worktreePolling ] + subs let relativeTime = ArchiveViews.relativeTime diff --git a/src/Server/RefreshScheduler.fs b/src/Server/RefreshScheduler.fs index 49c7aa4..4d02bee 100644 --- a/src/Server/RefreshScheduler.fs +++ b/src/Server/RefreshScheduler.fs @@ -35,7 +35,8 @@ type DashboardState = SchedulerEvents: CardEvent list PinnedErrors: Map LatestByCategory: Map - ExpeditedRepos: Set } + ExpeditedRepos: Set + ClientActivity: ActivityLevel } module DashboardState = let empty = @@ -43,7 +44,8 @@ module DashboardState = SchedulerEvents = [] PinnedErrors = Map.empty LatestByCategory = Map.empty - ExpeditedRepos = Set.empty } + ExpeditedRepos = Set.empty + ClientActivity = ActivityLevel.Idle } type StateMsg = | UpdateWorktreeList of repoId: RepoId * GitWorktree.WorktreeInfo list @@ -58,6 +60,7 @@ type StateMsg = | LogSchedulerEvent of CardEvent | ExpediteRefresh of RepoId | ClearExpedite of RepoId + | ReportClientActivity of ActivityLevel let private maxEvents = 50 @@ -160,6 +163,9 @@ let private processMessage (state: DashboardState) (msg: StateMsg) = | ClearExpedite repoId -> { state with ExpeditedRepos = state.ExpeditedRepos |> Set.remove repoId } + | ReportClientActivity activity -> + { state with ClientActivity = activity } + let createAgent () = MailboxProcessor.Start(fun inbox -> let rec loop (state: DashboardState) = @@ -187,13 +193,51 @@ let private taskLabel = function | RefreshPr repoId -> "PrFetch", RepoId.value repoId | RefreshFetch repoId -> "GitFetch", RepoId.value repoId -let private intervalOf = function - | RefreshWorktreeList _ -> TimeSpan.FromSeconds(15.0) - | RefreshGit _ -> TimeSpan.FromSeconds(15.0) - | RefreshBeads _ -> TimeSpan.FromSeconds(60.0) - | RefreshCodingTool _ -> TimeSpan.FromSeconds(15.0) - | RefreshPr _ -> TimeSpan.FromSeconds(120.0) - | RefreshFetch _ -> TimeSpan.FromSeconds(120.0) +let private intervalOf (activity: ActivityLevel) = function + | RefreshWorktreeList _ -> + match activity with + | ActivityLevel.Active -> TimeSpan.FromSeconds(10.0) + | ActivityLevel.Idle -> TimeSpan.FromSeconds(15.0) + | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(60.0) + | RefreshGit _ -> + match activity with + | ActivityLevel.Active -> TimeSpan.FromSeconds(5.0) + | ActivityLevel.Idle -> TimeSpan.FromSeconds(15.0) + | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(60.0) + | RefreshBeads _ -> + match activity with + | ActivityLevel.Active -> TimeSpan.FromSeconds(30.0) + | ActivityLevel.Idle -> TimeSpan.FromSeconds(60.0) + | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(240.0) + | RefreshCodingTool _ -> + match activity with + | ActivityLevel.Active -> TimeSpan.FromSeconds(5.0) + | ActivityLevel.Idle -> TimeSpan.FromSeconds(15.0) + | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(60.0) + | RefreshPr _ -> + match activity with + | ActivityLevel.Active -> TimeSpan.FromSeconds(10.0) + | ActivityLevel.Idle -> TimeSpan.FromSeconds(120.0) + | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(600.0) + | RefreshFetch _ -> + match activity with + | ActivityLevel.Active -> TimeSpan.FromSeconds(10.0) + | ActivityLevel.Idle -> TimeSpan.FromSeconds(120.0) + | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(600.0) + +let private codingToolActivityThreshold = TimeSpan.FromMinutes(5.0) + +let effectiveActivity (now: DateTimeOffset) (state: DashboardState) = + let hasCodingToolActivity = + state.Repos + |> Map.exists (fun _ repo -> + repo.CodingToolData + |> Map.exists (fun _ ct -> + ct.LastUserMessage + |> Option.exists (fun (_, ts) -> now - ts < codingToolActivityThreshold))) + + if hasCodingToolActivity then ActivityLevel.Active + else state.ClientActivity let readArchivedBranchSets (rootPaths: Map) = rootPaths @@ -264,10 +308,10 @@ let buildPhase2Tasks (archivedPaths: Map>) (repos: Map) = repos |> Map.toList |> List.map (fun (repoId, _) -> RefreshPr repoId) -let private deadlineOf (lastRuns: Map) (task: RefreshTask) = +let private deadlineOf (activity: ActivityLevel) (lastRuns: Map) (task: RefreshTask) = lastRuns |> Map.tryFind task - |> Option.map (fun t -> t + intervalOf task) + |> Option.map (fun t -> t + intervalOf activity task) |> Option.defaultValue DateTimeOffset.MinValue let private executeTask @@ -431,16 +475,16 @@ let runInitialBurst (agent: MailboxProcessor) (rootPaths: Map Map.ofList } -let pickMostOverdue (now: DateTimeOffset) (lastRuns: Map) (tasks: RefreshTask list) = +let pickMostOverdue (activity: ActivityLevel) (now: DateTimeOffset) (lastRuns: Map) (tasks: RefreshTask list) = tasks - |> List.filter (fun task -> deadlineOf lastRuns task <= now) - |> List.sortBy (deadlineOf lastRuns) + |> List.filter (fun task -> deadlineOf activity lastRuns task <= now) + |> List.sortBy (deadlineOf activity lastRuns) |> List.tryHead -let computeSleepMs (now: DateTimeOffset) (lastRuns: Map) (tasks: RefreshTask list) = +let computeSleepMs (activity: ActivityLevel) (now: DateTimeOffset) (lastRuns: Map) (tasks: RefreshTask list) = tasks |> List.map (fun task -> - let deadline = deadlineOf lastRuns task + let deadline = deadlineOf activity lastRuns task (deadline - now).TotalMilliseconds |> int) |> List.fold min Int32.MaxValue |> max 100 @@ -473,6 +517,7 @@ let start (agent: MailboxProcessor) (worktreeRoots: string list) (ct: let archivedPaths = resolveArchivedPaths archivedBranchSets repos let tasks = buildTaskList archivedPaths repos let now = DateTimeOffset.UtcNow + let activity = effectiveActivity now state let effectiveLastRuns = tasks @@ -482,7 +527,7 @@ let start (agent: MailboxProcessor) (worktreeRoots: string list) (ct: runs |> Map.remove task | _ -> runs) lastRuns - match pickMostOverdue now effectiveLastRuns tasks with + match pickMostOverdue activity now effectiveLastRuns tasks with | Some task -> let! result = executeWithTimeout agent rootPaths task logTaskResult agent task result @@ -495,7 +540,7 @@ let start (agent: MailboxProcessor) (worktreeRoots: string list) (ct: let updatedRuns = lastRuns |> Map.add task now return! loop updatedRuns | None -> - let sleepMs = computeSleepMs now effectiveLastRuns tasks + let sleepMs = computeSleepMs activity now effectiveLastRuns tasks do! Async.Sleep sleepMs return! loop lastRuns } diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index 76fb6c9..3ead553 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -493,4 +493,4 @@ let worktreeApi let command = CodingToolStatus.buildInteractiveCommand provider prompt return! SessionManager.launchAction sessionAgent req.Path command }) - reportActivity = fun _ -> async { return () } } + reportActivity = fun level -> async { agent.Post(RefreshScheduler.StateMsg.ReportClientActivity level) } } diff --git a/src/Tests/ConfirmModalTests.fs b/src/Tests/ConfirmModalTests.fs index ae01b5f..beaba3c 100644 --- a/src/Tests/ConfirmModalTests.fs +++ b/src/Tests/ConfirmModalTests.fs @@ -54,7 +54,9 @@ let private defaultModel : Model = ConfirmModal = ConfirmModal.NoConfirm DeletedPaths = Set.empty EditorName = "VS Code" - ActionCooldowns = Set.empty } + ActionCooldowns = Set.empty + LastActivityTime = 0.0 + ActivityLevel = ActivityLevel.Active } /// Calls update and returns the model, ignoring the Cmd. Handles the case where /// Fable.Remoting.Client proxy initialization fails in .NET by catching the /// TypeInitializationException (the model is computed before the Cmd). diff --git a/src/Tests/CreateWorktreeTests.fs b/src/Tests/CreateWorktreeTests.fs index 61f57d6..b965ec9 100644 --- a/src/Tests/CreateWorktreeTests.fs +++ b/src/Tests/CreateWorktreeTests.fs @@ -30,7 +30,9 @@ let private defaultModel : Model = ConfirmModal = ConfirmModal.NoConfirm DeletedPaths = Set.empty EditorName = "VS Code" - ActionCooldowns = Set.empty } + ActionCooldowns = Set.empty + LastActivityTime = 0.0 + ActivityLevel = ActivityLevel.Active } /// Calls update and returns the model, ignoring the Cmd. Handles the case where /// Fable.Remoting.Client proxy initialization fails in .NET by catching the diff --git a/src/Tests/SchedulerTests.fs b/src/Tests/SchedulerTests.fs index 5430bb1..5b22dd5 100644 --- a/src/Tests/SchedulerTests.fs +++ b/src/Tests/SchedulerTests.fs @@ -24,14 +24,14 @@ type PickMostOverdueTests() = [] member _.``Cold start with empty lastRuns returns first task``() = let tasks = [ RefreshWorktreeList testRepoId; RefreshPr testRepoId; RefreshGit(testRepoId, "/repo/a") ] - let result = pickMostOverdue DateTimeOffset.UtcNow Map.empty tasks + let result = pickMostOverdue ActivityLevel.Idle DateTimeOffset.UtcNow Map.empty tasks Assert.That(result.IsSome, Is.True) [] member _.``Cold start with empty lastRuns picks earliest deadline (all MinValue)``() = let tasks = [ RefreshWorktreeList testRepoId; RefreshPr testRepoId; RefreshGit(testRepoId, "/repo/a") ] - let result = pickMostOverdue DateTimeOffset.UtcNow Map.empty tasks + let result = pickMostOverdue ActivityLevel.Idle DateTimeOffset.UtcNow Map.empty tasks Assert.That(result, Is.EqualTo(Some(RefreshWorktreeList testRepoId))) @@ -45,7 +45,7 @@ type PickMostOverdueTests() = |> List.map (fun t -> t, now) |> Map.ofList - let result = pickMostOverdue now lastRuns tasks + let result = pickMostOverdue ActivityLevel.Idle now lastRuns tasks Assert.That(result, Is.EqualTo(None)) @@ -62,7 +62,7 @@ type PickMostOverdueTests() = RefreshGit(testRepoId, "/repo/a"), now ] |> Map.ofList - let result = pickMostOverdue now lastRuns tasks + let result = pickMostOverdue ActivityLevel.Idle now lastRuns tasks Assert.That(result, Is.EqualTo(Some(RefreshPr testRepoId))) @@ -80,13 +80,13 @@ type PickMostOverdueTests() = RefreshGit(testRepoId, "/repo/a"), now ] |> Map.ofList - let result = pickMostOverdue now lastRuns tasks + let result = pickMostOverdue ActivityLevel.Idle now lastRuns tasks Assert.That(result, Is.EqualTo(Some(RefreshPr testRepoId))) [] member _.``Empty task list returns None``() = - let result = pickMostOverdue DateTimeOffset.UtcNow Map.empty [] + let result = pickMostOverdue ActivityLevel.Idle DateTimeOffset.UtcNow Map.empty [] Assert.That(result, Is.EqualTo(None)) [] @@ -99,7 +99,7 @@ type PickMostOverdueTests() = let tasks = [ RefreshWorktreeList testRepoId; RefreshPr testRepoId ] - let result = pickMostOverdue now lastRuns tasks + let result = pickMostOverdue ActivityLevel.Idle now lastRuns tasks Assert.That(result, Is.EqualTo(Some(RefreshPr testRepoId))) @@ -119,7 +119,7 @@ type ComputeSleepMsTests() = RefreshPr testRepoId, now ] |> Map.ofList - let result = computeSleepMs now lastRuns tasks + let result = computeSleepMs ActivityLevel.Idle now lastRuns tasks Assert.That(result, Is.GreaterThan(1000)) @@ -133,7 +133,7 @@ type ComputeSleepMsTests() = [ RefreshPr testRepoId, longAgo ] |> Map.ofList - let result = computeSleepMs now lastRuns tasks + let result = computeSleepMs ActivityLevel.Idle now lastRuns tasks Assert.That(result, Is.EqualTo(100)) @@ -147,13 +147,13 @@ type ComputeSleepMsTests() = [ RefreshGit(testRepoId, "/repo/a"), ranTenSecondsAgo ] |> Map.ofList - let result = computeSleepMs now lastRuns tasks + let result = computeSleepMs ActivityLevel.Idle now lastRuns tasks Assert.That(result, Is.InRange(4000, 6000)) [] member _.``Empty task list returns max int``() = - let result = computeSleepMs DateTimeOffset.UtcNow Map.empty [] + let result = computeSleepMs ActivityLevel.Idle DateTimeOffset.UtcNow Map.empty [] Assert.That(result, Is.EqualTo(Int32.MaxValue)) [] @@ -166,7 +166,7 @@ type ComputeSleepMsTests() = RefreshPr testRepoId, now ] |> Map.ofList - let result = computeSleepMs now lastRuns tasks + let result = computeSleepMs ActivityLevel.Idle now lastRuns tasks Assert.That(result, Is.InRange(4000, 6000)) From 90d1757b960637095e94c83d8891506a43ef88d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Fri, 27 Mar 2026 18:54:56 +0100 Subject: [PATCH 03/11] tm-user-idle-4or.5 Fix: update worktree-monitor.md Refresh Intervals section Replaced fixed interval table with cross-reference to user-idle-detection.md. Added user-idle-detection.md to Related Specs section. --- docs/spec/worktree-monitor.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/spec/worktree-monitor.md b/docs/spec/worktree-monitor.md index b780f6f..3ac5839 100644 --- a/docs/spec/worktree-monitor.md +++ b/docs/spec/worktree-monitor.md @@ -155,11 +155,7 @@ Windows Terminal integration for spawning, tracking, and focusing terminal windo ### Refresh Intervals -| Category | Scope | Interval | -|----------|-------|----------| -| WorktreeList | per-repo | 60s | -| Git, Beads, CodingTool | per-worktree | 15s | -| PR, Fetch | per-repo | 120s | +Intervals adapt to user activity level (Active / Idle / Deep Idle). See `docs/spec/user-idle-detection.md` for the full interval table and activity state definitions. The Idle column matches the original fixed values shown here historically. ### PR Provider Routing @@ -236,6 +232,7 @@ After the burst, `lastRuns` is pre-populated and the normal sequential loop take ## Related Specs +- `docs/spec/user-idle-detection.md` — adaptive refresh cadence based on user activity level - `docs/spec/keyboard-navigation.md` — spatial arrow-key navigation and key bindings - `docs/spec/native-session-management.md` — Windows Terminal spawn/focus/kill via HWND tracking - `docs/spec/future/strong-typed-paths.md` — `AbsolutePath` wrapper type (deferred: entry-point normalization sufficient) From 40003f12131a603ce63a3eb5c29c81194cfb360e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Fri, 27 Mar 2026 19:00:39 +0100 Subject: [PATCH 04/11] tm-user-idle-4or.2/4or.3/4or.4 Fix review findings: initial reportActivity, timestamp/expiry, flatten intervalOf - Send initial reportActivity(Active) on app startup (4or.2) - Add ClientActivityAt timestamp to DashboardState and server-side timeout logic (4or.3) - Refactor intervalOf from nested match to flat tuple pattern (4or.4) --- src/Client/App.fs | 2 +- src/Server/RefreshScheduler.fs | 72 +++++++++++++++++----------------- src/Server/WorktreeApi.fs | 2 +- 3 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/Client/App.fs b/src/Client/App.fs index db26a83..221952e 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -104,7 +104,7 @@ let init () = ActionCooldowns = Set.empty LastActivityTime = Fable.Core.JS.Constructors.Date.now () ActivityLevel = ActivityLevel.Active }, - Cmd.batch [ fetchWorktrees (); fetchSyncStatus () ] + Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> Tick) ] let rng = System.Random() diff --git a/src/Server/RefreshScheduler.fs b/src/Server/RefreshScheduler.fs index 4d02bee..871824a 100644 --- a/src/Server/RefreshScheduler.fs +++ b/src/Server/RefreshScheduler.fs @@ -36,7 +36,8 @@ type DashboardState = PinnedErrors: Map LatestByCategory: Map ExpeditedRepos: Set - ClientActivity: ActivityLevel } + ClientActivity: ActivityLevel + ClientActivityAt: DateTimeOffset } module DashboardState = let empty = @@ -45,7 +46,8 @@ module DashboardState = PinnedErrors = Map.empty LatestByCategory = Map.empty ExpeditedRepos = Set.empty - ClientActivity = ActivityLevel.Idle } + ClientActivity = ActivityLevel.Idle + ClientActivityAt = DateTimeOffset.MinValue } type StateMsg = | UpdateWorktreeList of repoId: RepoId * GitWorktree.WorktreeInfo list @@ -60,7 +62,7 @@ type StateMsg = | LogSchedulerEvent of CardEvent | ExpediteRefresh of RepoId | ClearExpedite of RepoId - | ReportClientActivity of ActivityLevel + | ReportClientActivity of ActivityLevel * DateTimeOffset let private maxEvents = 50 @@ -163,8 +165,8 @@ let private processMessage (state: DashboardState) (msg: StateMsg) = | ClearExpedite repoId -> { state with ExpeditedRepos = state.ExpeditedRepos |> Set.remove repoId } - | ReportClientActivity activity -> - { state with ClientActivity = activity } + | ReportClientActivity(activity, timestamp) -> + { state with ClientActivity = activity; ClientActivityAt = timestamp } let createAgent () = MailboxProcessor.Start(fun inbox -> @@ -193,39 +195,30 @@ let private taskLabel = function | RefreshPr repoId -> "PrFetch", RepoId.value repoId | RefreshFetch repoId -> "GitFetch", RepoId.value repoId -let private intervalOf (activity: ActivityLevel) = function - | RefreshWorktreeList _ -> - match activity with - | ActivityLevel.Active -> TimeSpan.FromSeconds(10.0) - | ActivityLevel.Idle -> TimeSpan.FromSeconds(15.0) - | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(60.0) - | RefreshGit _ -> - match activity with - | ActivityLevel.Active -> TimeSpan.FromSeconds(5.0) - | ActivityLevel.Idle -> TimeSpan.FromSeconds(15.0) - | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(60.0) - | RefreshBeads _ -> - match activity with - | ActivityLevel.Active -> TimeSpan.FromSeconds(30.0) - | ActivityLevel.Idle -> TimeSpan.FromSeconds(60.0) - | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(240.0) - | RefreshCodingTool _ -> - match activity with - | ActivityLevel.Active -> TimeSpan.FromSeconds(5.0) - | ActivityLevel.Idle -> TimeSpan.FromSeconds(15.0) - | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(60.0) - | RefreshPr _ -> - match activity with - | ActivityLevel.Active -> TimeSpan.FromSeconds(10.0) - | ActivityLevel.Idle -> TimeSpan.FromSeconds(120.0) - | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(600.0) - | RefreshFetch _ -> - match activity with - | ActivityLevel.Active -> TimeSpan.FromSeconds(10.0) - | ActivityLevel.Idle -> TimeSpan.FromSeconds(120.0) - | ActivityLevel.DeepIdle -> TimeSpan.FromSeconds(600.0) +let private intervalOf (activity: ActivityLevel) (task: RefreshTask) = + match activity, task with + | ActivityLevel.Active, RefreshWorktreeList _ -> TimeSpan.FromSeconds(10.0) + | ActivityLevel.Idle, RefreshWorktreeList _ -> TimeSpan.FromSeconds(15.0) + | ActivityLevel.DeepIdle, RefreshWorktreeList _ -> TimeSpan.FromSeconds(60.0) + | ActivityLevel.Active, RefreshGit _ -> TimeSpan.FromSeconds(5.0) + | ActivityLevel.Idle, RefreshGit _ -> TimeSpan.FromSeconds(15.0) + | ActivityLevel.DeepIdle, RefreshGit _ -> TimeSpan.FromSeconds(60.0) + | ActivityLevel.Active, RefreshBeads _ -> TimeSpan.FromSeconds(30.0) + | ActivityLevel.Idle, RefreshBeads _ -> TimeSpan.FromSeconds(60.0) + | ActivityLevel.DeepIdle, RefreshBeads _ -> TimeSpan.FromSeconds(240.0) + | ActivityLevel.Active, RefreshCodingTool _ -> TimeSpan.FromSeconds(5.0) + | ActivityLevel.Idle, RefreshCodingTool _ -> TimeSpan.FromSeconds(15.0) + | ActivityLevel.DeepIdle, RefreshCodingTool _ -> TimeSpan.FromSeconds(60.0) + | ActivityLevel.Active, RefreshPr _ -> TimeSpan.FromSeconds(10.0) + | ActivityLevel.Idle, RefreshPr _ -> TimeSpan.FromSeconds(120.0) + | ActivityLevel.DeepIdle, RefreshPr _ -> TimeSpan.FromSeconds(600.0) + | ActivityLevel.Active, RefreshFetch _ -> TimeSpan.FromSeconds(10.0) + | ActivityLevel.Idle, RefreshFetch _ -> TimeSpan.FromSeconds(120.0) + | ActivityLevel.DeepIdle, RefreshFetch _ -> TimeSpan.FromSeconds(600.0) let private codingToolActivityThreshold = TimeSpan.FromMinutes(5.0) +let private clientActivityTimeout = TimeSpan.FromMinutes(5.0) +let private clientDeepIdleTimeout = TimeSpan.FromMinutes(20.0) let effectiveActivity (now: DateTimeOffset) (state: DashboardState) = let hasCodingToolActivity = @@ -237,7 +230,12 @@ let effectiveActivity (now: DateTimeOffset) (state: DashboardState) = |> Option.exists (fun (_, ts) -> now - ts < codingToolActivityThreshold))) if hasCodingToolActivity then ActivityLevel.Active - else state.ClientActivity + else + let elapsed = now - state.ClientActivityAt + + if elapsed >= clientDeepIdleTimeout then ActivityLevel.DeepIdle + elif elapsed >= clientActivityTimeout && state.ClientActivity = ActivityLevel.Active then ActivityLevel.Idle + else state.ClientActivity let readArchivedBranchSets (rootPaths: Map) = rootPaths diff --git a/src/Server/WorktreeApi.fs b/src/Server/WorktreeApi.fs index 3ead553..d04e6cd 100644 --- a/src/Server/WorktreeApi.fs +++ b/src/Server/WorktreeApi.fs @@ -493,4 +493,4 @@ let worktreeApi let command = CodingToolStatus.buildInteractiveCommand provider prompt return! SessionManager.launchAction sessionAgent req.Path command }) - reportActivity = fun level -> async { agent.Post(RefreshScheduler.StateMsg.ReportClientActivity level) } } + reportActivity = fun level -> async { agent.Post(RefreshScheduler.StateMsg.ReportClientActivity(level, DateTimeOffset.UtcNow)) } } From daee37e18b7c26c1cd9dc4622824ebed12526f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Fri, 27 Mar 2026 19:09:31 +0100 Subject: [PATCH 05/11] tm-user-idle-4or.3 Update spec: document server-side client activity decay decision --- docs/spec/user-idle-detection.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spec/user-idle-detection.md b/docs/spec/user-idle-detection.md index 4cd61be..c31ad06 100644 --- a/docs/spec/user-idle-detection.md +++ b/docs/spec/user-idle-detection.md @@ -77,3 +77,4 @@ New `reportActivity` endpoint posts `ReportClientActivity` to the scheduler agen - No multi-tab support — PWA, single tab assumed - Mutable state confined to subscription throttle closure only - `ActivityLevel` uses `[]` — its `Idle` case would shadow `CodingToolStatus.Idle` +- Server-side client activity decay: `ClientActivityAt` timestamp stored alongside level. If 5 min stale and was Active → Idle; if 20 min stale → DeepIdle regardless. Mirrors coding tool's 5-min pattern. Client only reports transitions (not periodic), so generous timeouts prevent false decay while connected. From 71904a9f07f357c1f1f836bc4e3929655cfa1654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Mon, 30 Mar 2026 10:44:47 +0200 Subject: [PATCH 06/11] tm-user-idle-o7y Add idle detection unit tests (41 tests) --- src/Server/RefreshScheduler.fs | 2 +- src/Tests/IdleDetectionTests.fs | 307 ++++++++++++++++++++++++++++++++ src/Tests/Tests.fsproj | 1 + 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 src/Tests/IdleDetectionTests.fs diff --git a/src/Server/RefreshScheduler.fs b/src/Server/RefreshScheduler.fs index 871824a..56e536d 100644 --- a/src/Server/RefreshScheduler.fs +++ b/src/Server/RefreshScheduler.fs @@ -195,7 +195,7 @@ let private taskLabel = function | RefreshPr repoId -> "PrFetch", RepoId.value repoId | RefreshFetch repoId -> "GitFetch", RepoId.value repoId -let private intervalOf (activity: ActivityLevel) (task: RefreshTask) = +let internal intervalOf (activity: ActivityLevel) (task: RefreshTask) = match activity, task with | ActivityLevel.Active, RefreshWorktreeList _ -> TimeSpan.FromSeconds(10.0) | ActivityLevel.Idle, RefreshWorktreeList _ -> TimeSpan.FromSeconds(15.0) diff --git a/src/Tests/IdleDetectionTests.fs b/src/Tests/IdleDetectionTests.fs new file mode 100644 index 0000000..be178ee --- /dev/null +++ b/src/Tests/IdleDetectionTests.fs @@ -0,0 +1,307 @@ +module Tests.IdleDetectionTests + +open System +open NUnit.Framework +open Server.RefreshScheduler +open Server.CodingToolStatus +open Shared + +let private testRepoId = RepoId "TestRepo" +let private now = DateTimeOffset(2026, 3, 27, 12, 0, 0, TimeSpan.Zero) + +// --- helpers --- + +let private emptyDashboard = + { DashboardState.empty with ClientActivity = ActivityLevel.Idle; ClientActivityAt = now } + +let private dashboardWithCodingTool (lastMessageAge: TimeSpan) = + let ct: CodingToolResult = + { Status = CodingToolStatus.Idle + Provider = None + LastUserMessage = Some("hello", now - lastMessageAge) + LastAssistantMessage = None } + + let repo = + { PerRepoState.empty with + CodingToolData = Map.ofList [ "/repo/a", ct ] } + + { DashboardState.empty with + Repos = Map.ofList [ testRepoId, repo ] + ClientActivity = ActivityLevel.Idle + ClientActivityAt = now } + +let private dashboardWithClientActivity (level: ActivityLevel) (activityAge: TimeSpan) = + { DashboardState.empty with + ClientActivity = level + ClientActivityAt = now - activityAge } + +// ==================== intervalOf tests ==================== + +[] +[] +[] +[] +type IntervalOfTests() = + + // --- WorktreeList --- + [] + member _.``Active WorktreeList returns 10s``() = + Assert.That(intervalOf ActivityLevel.Active (RefreshWorktreeList testRepoId), Is.EqualTo(TimeSpan.FromSeconds(10.0))) + + [] + member _.``Idle WorktreeList returns 15s``() = + Assert.That(intervalOf ActivityLevel.Idle (RefreshWorktreeList testRepoId), Is.EqualTo(TimeSpan.FromSeconds(15.0))) + + [] + member _.``DeepIdle WorktreeList returns 60s``() = + Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshWorktreeList testRepoId), Is.EqualTo(TimeSpan.FromSeconds(60.0))) + + // --- Git --- + [] + member _.``Active Git returns 5s``() = + Assert.That(intervalOf ActivityLevel.Active (RefreshGit(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(5.0))) + + [] + member _.``Idle Git returns 15s``() = + Assert.That(intervalOf ActivityLevel.Idle (RefreshGit(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(15.0))) + + [] + member _.``DeepIdle Git returns 60s``() = + Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshGit(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(60.0))) + + // --- Beads --- + [] + member _.``Active Beads returns 30s``() = + Assert.That(intervalOf ActivityLevel.Active (RefreshBeads(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(30.0))) + + [] + member _.``Idle Beads returns 60s``() = + Assert.That(intervalOf ActivityLevel.Idle (RefreshBeads(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(60.0))) + + [] + member _.``DeepIdle Beads returns 240s``() = + Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshBeads(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(240.0))) + + // --- CodingTool --- + [] + member _.``Active CodingTool returns 5s``() = + Assert.That(intervalOf ActivityLevel.Active (RefreshCodingTool(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(5.0))) + + [] + member _.``Idle CodingTool returns 15s``() = + Assert.That(intervalOf ActivityLevel.Idle (RefreshCodingTool(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(15.0))) + + [] + member _.``DeepIdle CodingTool returns 60s``() = + Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshCodingTool(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(60.0))) + + // --- PR --- + [] + member _.``Active PR returns 10s``() = + Assert.That(intervalOf ActivityLevel.Active (RefreshPr testRepoId), Is.EqualTo(TimeSpan.FromSeconds(10.0))) + + [] + member _.``Idle PR returns 120s``() = + Assert.That(intervalOf ActivityLevel.Idle (RefreshPr testRepoId), Is.EqualTo(TimeSpan.FromSeconds(120.0))) + + [] + member _.``DeepIdle PR returns 600s``() = + Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshPr testRepoId), Is.EqualTo(TimeSpan.FromSeconds(600.0))) + + // --- Fetch --- + [] + member _.``Active Fetch returns 10s``() = + Assert.That(intervalOf ActivityLevel.Active (RefreshFetch testRepoId), Is.EqualTo(TimeSpan.FromSeconds(10.0))) + + [] + member _.``Idle Fetch returns 120s``() = + Assert.That(intervalOf ActivityLevel.Idle (RefreshFetch testRepoId), Is.EqualTo(TimeSpan.FromSeconds(120.0))) + + [] + member _.``DeepIdle Fetch returns 600s``() = + Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshFetch testRepoId), Is.EqualTo(TimeSpan.FromSeconds(600.0))) + +// ==================== effectiveActivity tests ==================== + +[] +[] +[] +[] +type EffectiveActivityTests() = + + // --- Coding tool override --- + [] + member _.``Coding tool message within 5 min overrides to Active``() = + let state = dashboardWithCodingTool (TimeSpan.FromMinutes(3.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.Active)) + + [] + member _.``Coding tool message at exactly 5 min does not override``() = + let state = dashboardWithCodingTool (TimeSpan.FromMinutes(5.0)) + Assert.That(effectiveActivity now state, Is.Not.EqualTo(ActivityLevel.Active)) + + [] + member _.``Coding tool message older than 5 min does not override``() = + let state = dashboardWithCodingTool (TimeSpan.FromMinutes(10.0)) + Assert.That(effectiveActivity now state, Is.Not.EqualTo(ActivityLevel.Active)) + + [] + member _.``Coding tool message 1 second ago overrides Idle client to Active``() = + let ct: CodingToolResult = + { Status = CodingToolStatus.Idle + Provider = None + LastUserMessage = Some("test", now - TimeSpan.FromSeconds(1.0)) + LastAssistantMessage = None } + + let repo = + { PerRepoState.empty with + CodingToolData = Map.ofList [ "/repo/a", ct ] } + + let state = + { DashboardState.empty with + Repos = Map.ofList [ testRepoId, repo ] + ClientActivity = ActivityLevel.DeepIdle + ClientActivityAt = now - TimeSpan.FromHours(1.0) } + + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.Active)) + + // --- Empty data --- + [] + member _.``No repos returns client activity as-is when fresh``() = + let state = dashboardWithClientActivity ActivityLevel.Idle (TimeSpan.FromSeconds(30.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.Idle)) + + [] + member _.``No coding tool data with Active client returns Active when fresh``() = + let state = dashboardWithClientActivity ActivityLevel.Active (TimeSpan.FromSeconds(30.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.Active)) + + [] + member _.``No coding tool data with DeepIdle client returns DeepIdle when fresh``() = + let state = dashboardWithClientActivity ActivityLevel.DeepIdle (TimeSpan.FromSeconds(30.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.DeepIdle)) + + // --- Client activity decay --- + [] + member _.``Active client stale 5 min decays to Idle``() = + let state = dashboardWithClientActivity ActivityLevel.Active (TimeSpan.FromMinutes(5.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.Idle)) + + [] + member _.``Active client stale 4m59s does not decay``() = + let state = dashboardWithClientActivity ActivityLevel.Active (TimeSpan.FromMinutes(5.0) - TimeSpan.FromSeconds(1.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.Active)) + + [] + member _.``Active client stale 20 min decays to DeepIdle``() = + let state = dashboardWithClientActivity ActivityLevel.Active (TimeSpan.FromMinutes(20.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.DeepIdle)) + + [] + member _.``Idle client stale 20 min decays to DeepIdle``() = + let state = dashboardWithClientActivity ActivityLevel.Idle (TimeSpan.FromMinutes(20.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.DeepIdle)) + + [] + member _.``Idle client stale 10 min stays Idle (no mid-decay to Active)``() = + // Idle client at 10 min: not >= 20 min so no DeepIdle; + // the Active->Idle decay only triggers when ClientActivity = Active, so Idle stays Idle + let state = dashboardWithClientActivity ActivityLevel.Idle (TimeSpan.FromMinutes(10.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.Idle)) + + [] + member _.``DeepIdle client stale 20 min stays DeepIdle``() = + let state = dashboardWithClientActivity ActivityLevel.DeepIdle (TimeSpan.FromMinutes(20.0)) + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.DeepIdle)) + + [] + member _.``Coding tool with no LastUserMessage does not override``() = + let ct: CodingToolResult = + { Status = CodingToolStatus.Idle + Provider = None + LastUserMessage = None + LastAssistantMessage = None } + + let repo = + { PerRepoState.empty with + CodingToolData = Map.ofList [ "/repo/a", ct ] } + + let state = + { DashboardState.empty with + Repos = Map.ofList [ testRepoId, repo ] + ClientActivity = ActivityLevel.Idle + ClientActivityAt = now } + + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.Idle)) + + [] + member _.``Multiple repos - one has recent coding tool activity overrides to Active``() = + let recentCt: CodingToolResult = + { Status = CodingToolStatus.Idle + Provider = None + LastUserMessage = Some("recent", now - TimeSpan.FromMinutes(1.0)) + LastAssistantMessage = None } + + let staleCt: CodingToolResult = + { Status = CodingToolStatus.Idle + Provider = None + LastUserMessage = Some("stale", now - TimeSpan.FromMinutes(10.0)) + LastAssistantMessage = None } + + let repo1 = + { PerRepoState.empty with + CodingToolData = Map.ofList [ "/repo/a", staleCt ] } + + let repo2 = + { PerRepoState.empty with + CodingToolData = Map.ofList [ "/repo/b", recentCt ] } + + let state = + { DashboardState.empty with + Repos = Map.ofList [ RepoId "Repo1", repo1; RepoId "Repo2", repo2 ] + ClientActivity = ActivityLevel.DeepIdle + ClientActivityAt = now - TimeSpan.FromHours(1.0) } + + Assert.That(effectiveActivity now state, Is.EqualTo(ActivityLevel.Active)) + +// ==================== computeActivityLevel (client) tests ==================== + +[] +[] +[] +[] +type ComputeActivityLevelTests() = + + let nowMs = 1000000.0 // arbitrary "now" in milliseconds + + [] + member _.``Recent activity (0 elapsed) returns Active``() = + Assert.That(App.computeActivityLevel nowMs nowMs, Is.EqualTo(ActivityLevel.Active)) + + [] + member _.``Activity 30s ago returns Active``() = + Assert.That(App.computeActivityLevel (nowMs - 30_000.0) nowMs, Is.EqualTo(ActivityLevel.Active)) + + [] + member _.``Activity 59.999s ago returns Active``() = + Assert.That(App.computeActivityLevel (nowMs - 59_999.0) nowMs, Is.EqualTo(ActivityLevel.Active)) + + [] + member _.``Activity exactly 60s ago returns Idle``() = + Assert.That(App.computeActivityLevel (nowMs - 60_000.0) nowMs, Is.EqualTo(ActivityLevel.Idle)) + + [] + member _.``Activity 5 min ago returns Idle``() = + Assert.That(App.computeActivityLevel (nowMs - 300_000.0) nowMs, Is.EqualTo(ActivityLevel.Idle)) + + [] + member _.``Activity 14m59s ago returns Idle``() = + Assert.That(App.computeActivityLevel (nowMs - 899_999.0) nowMs, Is.EqualTo(ActivityLevel.Idle)) + + [] + member _.``Activity exactly 15 min ago returns DeepIdle``() = + Assert.That(App.computeActivityLevel (nowMs - 900_000.0) nowMs, Is.EqualTo(ActivityLevel.DeepIdle)) + + [] + member _.``Activity 1 hour ago returns DeepIdle``() = + Assert.That(App.computeActivityLevel (nowMs - 3_600_000.0) nowMs, Is.EqualTo(ActivityLevel.DeepIdle)) diff --git a/src/Tests/Tests.fsproj b/src/Tests/Tests.fsproj index 0ff5128..261859e 100644 --- a/src/Tests/Tests.fsproj +++ b/src/Tests/Tests.fsproj @@ -12,6 +12,7 @@ + From 95d7b71879f20722a6f2c0c537da82f013ef7c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Mon, 30 Mar 2026 16:46:59 +0200 Subject: [PATCH 07/11] Fix activity reporting bug and make update function pure - Report activity to server on every tick, not just transitions Fixes server decaying to Idle after 5min while user is active - Pass timestamps via Msg payloads (Tick of float, UserActivity of float) instead of calling Date.now() in update handlers - Update elmish-mvu-discipline rule to require pure update functions - Update no-unnecessary-comments rule to allow section dividers in tests --- review/rules/elmish-mvu-discipline.md | 1 + review/rules/no-unnecessary-comments.md | 1 + src/Client/App.fs | 38 ++++++++++--------------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/review/rules/elmish-mvu-discipline.md b/review/rules/elmish-mvu-discipline.md index 0301d4f..9465187 100644 --- a/review/rules/elmish-mvu-discipline.md +++ b/review/rules/elmish-mvu-discipline.md @@ -14,6 +14,7 @@ The MVU (Model-View-Update) pattern guarantees unidirectional data flow and make ## Requirements - All user interactions in view functions must dispatch a `Msg` — never call APIs, mutate state, or perform side effects inline - Side effects (API calls, DOM manipulation, timers) must be expressed as `Cmd` values returned from `update`, never executed directly +- The `update` function must be pure: same `(Msg, Model)` must produce the same `(Model, Cmd)`. Non-deterministic values (clocks, random) must be captured at the impure boundary (subscriptions, Cmd callbacks) and passed via `Msg` payloads - No mutable refs (`ref`, `Ref`, `IRefValue`) used to share state between view renders or between view and update - No direct DOM manipulation (e.g., `document.getElementById`, `element.style`, `element.classList`) in view functions — use React props/attributes instead - No `async { ... } |> Async.StartImmediate` or `promise { ... }` fire-and-forget in view functions — use `Cmd.OfAsync` or `Cmd.OfPromise` in update diff --git a/review/rules/no-unnecessary-comments.md b/review/rules/no-unnecessary-comments.md index 8eb229f..a350122 100644 --- a/review/rules/no-unnecessary-comments.md +++ b/review/rules/no-unnecessary-comments.md @@ -17,6 +17,7 @@ Redundant comments add noise and become stale. Code should be self-documenting t - No TODO comments - Only include comments that explain non-obvious algorithms or warn about critical edge cases that can't be expressed through naming - XML doc comments on public API are acceptable but should add value beyond the name +- **Test files exception:** Section dividers (`// --- Name ---`, `// === Name ===`) are acceptable in test files for navigating long fixtures. In production code, dividers usually indicate code that should be extracted into named functions — flag those. ## Wrong ```fsharp diff --git a/src/Client/App.fs b/src/Client/App.fs index 221952e..b3fcca4 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -39,7 +39,7 @@ type Msg = | ToggleSort | ToggleCompact | ToggleCollapse of repoId: RepoId - | Tick + | Tick of now: float | OpenTerminal of WorktreePath | OpenEditor of WorktreePath | StartSync of path: WorktreePath * scopedKey: string @@ -63,7 +63,7 @@ type Msg = | LaunchActionResult of Result | ClearActionCooldown of WorktreePath | ModalMsg of CreateWorktreeModal.Msg - | UserActivity + | UserActivity of now: float let worktreeApi = Remoting.createApi () @@ -104,7 +104,7 @@ let init () = ActionCooldowns = Set.empty LastActivityTime = Fable.Core.JS.Constructors.Date.now () ActivityLevel = ActivityLevel.Active }, - Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> Tick) ] + Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) ] let rng = System.Random() @@ -251,32 +251,24 @@ let update msg model = Cmd.none | OpenTerminal path -> - model, Cmd.OfAsync.attempt worktreeApi.openTerminal path (fun _ -> Tick) + model, Cmd.OfAsync.attempt worktreeApi.openTerminal path (fun _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) | OpenEditor path -> - model, Cmd.OfAsync.attempt worktreeApi.openEditor path (fun _ -> Tick) + model, Cmd.OfAsync.attempt worktreeApi.openEditor path (fun _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) - | Tick -> - let now = Fable.Core.JS.Constructors.Date.now () + | Tick now -> let newLevel = computeActivityLevel model.LastActivityTime now - let transitionCmd = - if newLevel <> model.ActivityLevel then - Cmd.OfAsync.attempt worktreeApi.reportActivity newLevel (fun _ -> Tick) - else - Cmd.none - { model with ActivityLevel = newLevel }, - Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); transitionCmd ] + Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); Cmd.OfAsync.attempt worktreeApi.reportActivity newLevel (fun _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) ] - | UserActivity -> - let now = Fable.Core.JS.Constructors.Date.now () + | UserActivity now -> let wasActive = model.ActivityLevel = ActivityLevel.Active let wakeUpCmd = if not wasActive then Cmd.batch [ - Cmd.ofMsg Tick - Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> Tick) + Cmd.ofMsg (Tick(Fable.Core.JS.Constructors.Date.now ())) + Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) ] else Cmd.none @@ -314,7 +306,7 @@ let update msg model = { model with BranchEvents = events }, Cmd.none | CancelSync path -> - model, Cmd.OfAsync.attempt worktreeApi.cancelSync path (fun _ -> Tick) + model, Cmd.OfAsync.attempt worktreeApi.cancelSync path (fun _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) | SyncTick -> model, fetchSyncStatus () @@ -349,13 +341,13 @@ let update msg model = | ConfirmModal.DeleteAfterKillSession path -> model, Cmd.OfAsync.perform worktreeApi.killSession path (function | Ok () -> SessionKilledForDelete path - | Error _ -> Tick) + | Error _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) | ConfirmModal.Archive path -> model, Cmd.ofMsg (ArchiveMsg (ArchiveViews.Archive path)) | ConfirmModal.ArchiveAfterKillSession path -> model, Cmd.OfAsync.perform worktreeApi.killSession path (function | Ok () -> SessionKilledForArchive path - | Error _ -> Tick) + | Error _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) | DeleteCompleted (Ok _) -> model, fetchWorktrees () @@ -479,7 +471,7 @@ let pollingSubscription (model: Model) : Sub = let worktreePolling (dispatch: Dispatch) = let intervalId = - Fable.Core.JS.setInterval (fun () -> dispatch Tick) pollingIntervalMs + Fable.Core.JS.setInterval (fun () -> dispatch (Tick(Fable.Core.JS.Constructors.Date.now ()))) pollingIntervalMs { new System.IDisposable with member _.Dispose() = Fable.Core.JS.clearInterval intervalId } @@ -498,7 +490,7 @@ let pollingSubscription (model: Model) : Sub = let now = Fable.Core.JS.Constructors.Date.now () if now - lastDispatchTime >= throttleMs then lastDispatchTime <- now - dispatch UserActivity + dispatch (UserActivity now) let events = [| "mousemove"; "keydown"; "click"; "scroll" |] events |> Array.iter (fun evt -> Dom.document.addEventListener (evt, handler)) From 5279dd85e135e27a50dadda6ceb2cfaff46ec6da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Tue, 31 Mar 2026 17:17:10 +0200 Subject: [PATCH 08/11] Visual idle indicator: droopy eyelid on Idle/DeepIdle - Idle (3min): lid ~25% closed with darker fill - DeepIdle (15min): lid ~50% closed - Removed opacity change on eye-closed for consistency - Bumped idle threshold from 60s to 180s --- src/Client/App.fs | 30 +++++++++++++++++++++++++++--- src/Client/index.html | 2 +- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Client/App.fs b/src/Client/App.fs index b3fcca4..6bf1517 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -161,7 +161,7 @@ let keyBinding (focused: FocusTarget) (key: string) (model: Model) : Msg option | RepoHeader repoId, "+" -> Some (ModalMsg (CreateWorktreeModal.OpenCreateWorktree repoId)) | _ -> None -let idleThresholdMs = 60_000.0 +let idleThresholdMs = 180_000.0 let deepIdleThresholdMs = 900_000.0 let computeActivityLevel (lastActivityTime: float) (now: float) = @@ -1192,7 +1192,7 @@ let sortLabel = | ByName -> "A-Z" | ByActivity -> "Recent" -let viewEyeOpen (pupilColor: string) (dx: float, dy: float) = +let viewEyeOpen (pupilColor: string) (activity: ActivityLevel) (dx: float, dy: float) = Svg.svg [ svg.className "eye-logo" svg.viewBox (-2, -2, 44, 24) @@ -1233,6 +1233,30 @@ let viewEyeOpen (pupilColor: string) (dx: float, dy: float) = svg.r 2 svg.fill "rgba(255, 255, 255, 0.8)" ] + match activity with + | ActivityLevel.Idle -> + Svg.path [ + svg.d "M2 10 Q10 0 20 0 Q30 0 38 10 Q30 4 20 5 Q10 4 2 10 Z" + svg.fill "#b0b0b0" + ] + Svg.path [ + svg.d "M2 10 Q10 4 20 5 Q30 4 38 10" + svg.fill "none" + svg.stroke "#56b6c2" + svg.strokeWidth 2.0 + ] + | ActivityLevel.DeepIdle -> + Svg.path [ + svg.d "M2 10 Q10 0 20 0 Q30 0 38 10 Q30 9 20 12 Q10 9 2 10 Z" + svg.fill "#b0b0b0" + ] + Svg.path [ + svg.d "M2 10 Q10 9 20 12 Q30 9 38 10" + svg.fill "none" + svg.stroke "#56b6c2" + svg.strokeWidth 2.0 + ] + | _ -> () ] ] @@ -1473,7 +1497,7 @@ let viewAppHeader model dispatch = if model.HasError then viewEyeRolledBack elif hasAnyActive model.Repos then let pupilColor = if hasAnyWaiting model.Repos then "#f9e2af" else "#1a1b2e" - viewEyeOpen pupilColor model.EyeDirection + viewEyeOpen pupilColor model.ActivityLevel model.EyeDirection else viewEyeClosed ] ] diff --git a/src/Client/index.html b/src/Client/index.html index f84cded..decb862 100644 --- a/src/Client/index.html +++ b/src/Client/index.html @@ -24,7 +24,7 @@ .header-right { display: flex; align-items: center; gap: 12px; } .app-header .eye-logo { width: 40px; height: 25px; margin-top: 0; } .eye-logo { display: block; opacity: 0.7; } - .eye-closed { opacity: 0.4; } + .eye-closed { } .eye-iris { transition: transform 0.3s ease; } .deploy-branch { background: #3a2e20; color: #f9e2af; font-size: 0.78em; font-weight: 600; From fafdb766a98c6ed9cd4d0f011cada18257b1a773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Tue, 31 Mar 2026 17:36:28 +0200 Subject: [PATCH 09/11] Update idle threshold from 60s to 3 min in spec and tests The implementation uses 180s (3 min) for the idle threshold. Update spec and boundary tests to match. --- docs/spec/user-idle-detection.md | 8 ++++---- src/Tests/IdleDetectionTests.fs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/spec/user-idle-detection.md b/docs/spec/user-idle-detection.md index c31ad06..5901721 100644 --- a/docs/spec/user-idle-detection.md +++ b/docs/spec/user-idle-detection.md @@ -15,11 +15,11 @@ Three states derived from two signals (dashboard interaction + coding tool messa | State | Condition | |-------|-----------| -| Active | Dashboard interaction within last 60s, OR any coding tool user message within last 5 min | -| Idle | No dashboard interaction for 60s AND no recent coding tool messages | +| Active | Dashboard interaction within last 3 min, OR any coding tool user message within last 5 min | +| Idle | No dashboard interaction for 3 min AND no recent coding tool messages | | Deep Idle | No dashboard interaction for 15 min AND no recent coding tool messages | -Dashboard interaction = `mousemove`, `keydown`, `click`, `scroll` on the document. Tracked coarse-grained (throttled to 1 dispatch per 5s). App runs as a PWA — no tab-switching concerns; when minimized, mouse events stop and the 60s timer kicks in naturally. +Dashboard interaction = `mousemove`, `keydown`, `click`, `scroll` on the document. Tracked coarse-grained (throttled to 1 dispatch per 5s). App runs as a PWA — no tab-switching concerns; when minimized, mouse events stop and the 3-min timer kicks in naturally. ### Refresh Intervals @@ -51,7 +51,7 @@ New `ActivityLevel` DU: `Active | Idle | DeepIdle`. New `reportActivity: Activit Elmish subscription registers DOM event listeners (mousemove, keydown, click, scroll). Throttled to dispatch `UserActivity` at most once per 5s — the throttle uses a mutable timestamp inside the subscription closure (Elmish's designated impure boundary, same pattern as `setInterval`). The Model stays fully immutable with `LastActivityTime: float` and `ActivityLevel: ActivityLevel`. -`computeActivityLevel` is a pure function: compares `Date.now() - lastActivity` against 60s/15min thresholds. +`computeActivityLevel` is a pure function: compares `Date.now() - lastActivity` against 3min/15min thresholds. `pollingSubscription` includes activity level in its key so Elmish tears down and recreates the interval on transitions. Active/Idle = 1s, DeepIdle = 15s. diff --git a/src/Tests/IdleDetectionTests.fs b/src/Tests/IdleDetectionTests.fs index be178ee..80e0545 100644 --- a/src/Tests/IdleDetectionTests.fs +++ b/src/Tests/IdleDetectionTests.fs @@ -283,12 +283,12 @@ type ComputeActivityLevelTests() = Assert.That(App.computeActivityLevel (nowMs - 30_000.0) nowMs, Is.EqualTo(ActivityLevel.Active)) [] - member _.``Activity 59.999s ago returns Active``() = - Assert.That(App.computeActivityLevel (nowMs - 59_999.0) nowMs, Is.EqualTo(ActivityLevel.Active)) + member _.``Activity 2m59.999s ago returns Active``() = + Assert.That(App.computeActivityLevel (nowMs - 179_999.0) nowMs, Is.EqualTo(ActivityLevel.Active)) [] - member _.``Activity exactly 60s ago returns Idle``() = - Assert.That(App.computeActivityLevel (nowMs - 60_000.0) nowMs, Is.EqualTo(ActivityLevel.Idle)) + member _.``Activity exactly 3 min ago returns Idle``() = + Assert.That(App.computeActivityLevel (nowMs - 180_000.0) nowMs, Is.EqualTo(ActivityLevel.Idle)) [] member _.``Activity 5 min ago returns Idle``() = From e659d43bd13c787f4f7aa16de3cb3b47e58716aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Tue, 31 Mar 2026 20:40:23 +0200 Subject: [PATCH 10/11] Fix code review findings: transitions-only reporting, retry loop, purity, naming - Only call reportActivity on activity level transitions, not every Tick - Replace error callbacks with NoOp to prevent unbounded retry loop - Use message payload timestamp instead of Date.now() in update - Rename pollingSubscription to appSubscriptions - Replace wildcard match with explicit ActivityLevel.Active case - Update stale spec text for variable 1-15s polling cadence - Delete 18 zero-value IntervalOfTests (flat lookup assertions) --- docs/spec/worktree-monitor.md | 4 +- src/Client/App.fs | 23 ++++++--- src/Tests/IdleDetectionTests.fs | 86 --------------------------------- 3 files changed, 18 insertions(+), 95 deletions(-) diff --git a/docs/spec/worktree-monitor.md b/docs/spec/worktree-monitor.md index 3ac5839..f4ccc6e 100644 --- a/docs/spec/worktree-monitor.md +++ b/docs/spec/worktree-monitor.md @@ -151,7 +151,7 @@ Windows Terminal integration for spawning, tracking, and focusing terminal windo - `MailboxProcessor` state agent with `Map` — each repo has its own data partitions - Tail-recursive async loop picks most-overdue task, executes it, posts result to mailbox - API responses are instant reads from in-memory state -- Client polls every 1s; 2s fast poll during active sync +- Client polls every 1–15s depending on activity level (see `docs/spec/user-idle-detection.md`); 2s fast poll during active sync ### Refresh Intervals @@ -214,7 +214,7 @@ After the burst, `lastRuns` is pre-populated and the normal sequential loop take - Web app over TUI: richer layout, easy to keep open in a browser tab - F# + Fable/Elmish: single language both sides, shared types - MailboxProcessor over TTL cache: caps concurrent processes, instant API reads -- Polling over WebSocket: simpler, sufficient at 1s +- Polling over WebSocket: simpler, sufficient at 1–15s variable cadence (activity-based) - Most-overdue task selection: no cursor state, naturally prevents starvation - `gh`/`az` CLI over raw REST: handles auth, consistent pattern - Single API call returns all repos: client doesn't need to know repo count diff --git a/src/Client/App.fs b/src/Client/App.fs index 6bf1517..5b22a9e 100644 --- a/src/Client/App.fs +++ b/src/Client/App.fs @@ -64,6 +64,7 @@ type Msg = | ClearActionCooldown of WorktreePath | ModalMsg of CreateWorktreeModal.Msg | UserActivity of now: float + | NoOp let worktreeApi = Remoting.createApi () @@ -104,7 +105,7 @@ let init () = ActionCooldowns = Set.empty LastActivityTime = Fable.Core.JS.Constructors.Date.now () ActivityLevel = ActivityLevel.Active }, - Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) ] + Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> NoOp) ] let rng = System.Random() @@ -258,8 +259,14 @@ let update msg model = | Tick now -> let newLevel = computeActivityLevel model.LastActivityTime now + let reportCmd = + if newLevel <> model.ActivityLevel then + Cmd.OfAsync.attempt worktreeApi.reportActivity newLevel (fun _ -> NoOp) + else + Cmd.none + { model with ActivityLevel = newLevel }, - Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); Cmd.OfAsync.attempt worktreeApi.reportActivity newLevel (fun _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) ] + Cmd.batch [ fetchWorktrees (); fetchSyncStatus (); reportCmd ] | UserActivity now -> let wasActive = model.ActivityLevel = ActivityLevel.Active @@ -267,8 +274,8 @@ let update msg model = let wakeUpCmd = if not wasActive then Cmd.batch [ - Cmd.ofMsg (Tick(Fable.Core.JS.Constructors.Date.now ())) - Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> Tick(Fable.Core.JS.Constructors.Date.now ())) + Cmd.ofMsg (Tick now) + Cmd.OfAsync.attempt worktreeApi.reportActivity ActivityLevel.Active (fun _ -> NoOp) ] else Cmd.none @@ -457,7 +464,9 @@ let update msg model = | Some action -> model, Cmd.ofMsg action | None -> model, Cmd.none -let pollingSubscription (model: Model) : Sub = + | NoOp -> model, Cmd.none + +let appSubscriptions (model: Model) : Sub = let pollingIntervalMs = match model.ActivityLevel with | ActivityLevel.Active | ActivityLevel.Idle -> 1000 @@ -1256,7 +1265,7 @@ let viewEyeOpen (pupilColor: string) (activity: ActivityLevel) (dx: float, dy: f svg.stroke "#56b6c2" svg.strokeWidth 2.0 ] - | _ -> () + | ActivityLevel.Active -> () ] ] @@ -1569,6 +1578,6 @@ let view model dispatch = open Elmish.React Program.mkProgram init update view -|> Program.withSubscription pollingSubscription +|> Program.withSubscription appSubscriptions |> Program.withReactSynchronous "app" |> Program.run diff --git a/src/Tests/IdleDetectionTests.fs b/src/Tests/IdleDetectionTests.fs index 80e0545..eafd284 100644 --- a/src/Tests/IdleDetectionTests.fs +++ b/src/Tests/IdleDetectionTests.fs @@ -35,92 +35,6 @@ let private dashboardWithClientActivity (level: ActivityLevel) (activityAge: Tim ClientActivity = level ClientActivityAt = now - activityAge } -// ==================== intervalOf tests ==================== - -[] -[] -[] -[] -type IntervalOfTests() = - - // --- WorktreeList --- - [] - member _.``Active WorktreeList returns 10s``() = - Assert.That(intervalOf ActivityLevel.Active (RefreshWorktreeList testRepoId), Is.EqualTo(TimeSpan.FromSeconds(10.0))) - - [] - member _.``Idle WorktreeList returns 15s``() = - Assert.That(intervalOf ActivityLevel.Idle (RefreshWorktreeList testRepoId), Is.EqualTo(TimeSpan.FromSeconds(15.0))) - - [] - member _.``DeepIdle WorktreeList returns 60s``() = - Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshWorktreeList testRepoId), Is.EqualTo(TimeSpan.FromSeconds(60.0))) - - // --- Git --- - [] - member _.``Active Git returns 5s``() = - Assert.That(intervalOf ActivityLevel.Active (RefreshGit(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(5.0))) - - [] - member _.``Idle Git returns 15s``() = - Assert.That(intervalOf ActivityLevel.Idle (RefreshGit(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(15.0))) - - [] - member _.``DeepIdle Git returns 60s``() = - Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshGit(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(60.0))) - - // --- Beads --- - [] - member _.``Active Beads returns 30s``() = - Assert.That(intervalOf ActivityLevel.Active (RefreshBeads(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(30.0))) - - [] - member _.``Idle Beads returns 60s``() = - Assert.That(intervalOf ActivityLevel.Idle (RefreshBeads(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(60.0))) - - [] - member _.``DeepIdle Beads returns 240s``() = - Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshBeads(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(240.0))) - - // --- CodingTool --- - [] - member _.``Active CodingTool returns 5s``() = - Assert.That(intervalOf ActivityLevel.Active (RefreshCodingTool(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(5.0))) - - [] - member _.``Idle CodingTool returns 15s``() = - Assert.That(intervalOf ActivityLevel.Idle (RefreshCodingTool(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(15.0))) - - [] - member _.``DeepIdle CodingTool returns 60s``() = - Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshCodingTool(testRepoId, "/repo/a")), Is.EqualTo(TimeSpan.FromSeconds(60.0))) - - // --- PR --- - [] - member _.``Active PR returns 10s``() = - Assert.That(intervalOf ActivityLevel.Active (RefreshPr testRepoId), Is.EqualTo(TimeSpan.FromSeconds(10.0))) - - [] - member _.``Idle PR returns 120s``() = - Assert.That(intervalOf ActivityLevel.Idle (RefreshPr testRepoId), Is.EqualTo(TimeSpan.FromSeconds(120.0))) - - [] - member _.``DeepIdle PR returns 600s``() = - Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshPr testRepoId), Is.EqualTo(TimeSpan.FromSeconds(600.0))) - - // --- Fetch --- - [] - member _.``Active Fetch returns 10s``() = - Assert.That(intervalOf ActivityLevel.Active (RefreshFetch testRepoId), Is.EqualTo(TimeSpan.FromSeconds(10.0))) - - [] - member _.``Idle Fetch returns 120s``() = - Assert.That(intervalOf ActivityLevel.Idle (RefreshFetch testRepoId), Is.EqualTo(TimeSpan.FromSeconds(120.0))) - - [] - member _.``DeepIdle Fetch returns 600s``() = - Assert.That(intervalOf ActivityLevel.DeepIdle (RefreshFetch testRepoId), Is.EqualTo(TimeSpan.FromSeconds(600.0))) - // ==================== effectiveActivity tests ==================== [] From a92722e8ccd459f787826de7fb0dce31982dd463 Mon Sep 17 00:00:00 2001 From: Petr Pokorny Date: Thu, 23 Apr 2026 13:03:59 +0200 Subject: [PATCH 11/11] fix --- src/Tests/SmokeTests.fs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Tests/SmokeTests.fs b/src/Tests/SmokeTests.fs index 6459118..b55695f 100644 --- a/src/Tests/SmokeTests.fs +++ b/src/Tests/SmokeTests.fs @@ -333,6 +333,14 @@ type MultiRepoSmokeTests() = do! sections.First.WaitForAsync(LocatorWaitForOptions(Timeout = 15000.0f)) let! count = sections.CountAsync() + // Expand any collapsed sections first + let collapsedHeaders = this.Page.Locator(".repo-section .repo-header.collapsed") + let! collapsedCount = collapsedHeaders.CountAsync() + for idx in 0 .. collapsedCount - 1 do + do! collapsedHeaders.Nth(idx).ClickAsync() + if collapsedCount > 0 then + do! this.Page.WaitForTimeoutAsync(2000.0f) + for idx in 0 .. count - 1 do let section = sections.Nth(idx) let! repoName = section.Locator(".repo-name").TextContentAsync()