diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index 542cdf38a..ae9a9217f 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -55,7 +55,8 @@ A row whose pane is bound to an active workflow run replaces its subtitle with ## Interactions - **Click a row** → focuses that worktree + tab + pane and brings Prowl forward. A - **Done** row downgrades to **Idle** once focused. + **Done** row downgrades to **Idle** once viewed in the active, visible Prowl window. + Internal focus changes while the window is inactive do not clear the completion. - **Right-click a row** for the context menu: - **Hand Off…** — opens the Hand Off HUD for that agent's pane (selecting and focusing it first), regardless of which pane currently has diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 82ca7329e..bea419a8a 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -115,7 +115,10 @@ frames keep the last trusted state instead of forcing Idle. | **Done** | raw `idle` + **unseen** | just finished; you haven't looked yet | | **Idle** | raw `idle` + **seen** | nothing running | -A **Done** pane becomes **Idle** the moment you focus it. +A **Done** pane becomes **Idle** when it is actually viewed: its worktree and tab are selected, +its pane is focused, and the Prowl window is key and visible. Keeping a pane selected while +Prowl is inactive, hidden, or minimized does not mark its completion as read. Unknown window +state is conservatively treated as not viewed. See [Canvas](canvas.md) for Canvas-specific behavior. ## Cooperative signal bus diff --git a/docs/components/agent-island.md b/docs/components/agent-island.md index d944e6b2f..fc82692f6 100644 --- a/docs/components/agent-island.md +++ b/docs/components/agent-island.md @@ -61,7 +61,10 @@ badge takes the subtitle position, as in the sidebar. The displayed priority ord first, then unviewed Done, newest first within each state. Cells cannot be dismissed from the island. A Blocked cell clears when the agent leaves that state, a Done cell clears once the entry is viewed, -and a removed entry disappears with the roster. +and a removed entry disappears with the roster. A selected pane is not considered viewed while +Prowl's window is inactive, hidden, or minimized, so its Done reminder remains. Unknown window +state also retains reminders rather than automatically marking them read. See [Canvas](canvas.md) +for Canvas-specific behavior. ## Interactions diff --git a/docs/concepts.md b/docs/concepts.md index 85cbeb373..511a7a762 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -65,7 +65,7 @@ user-visible states: - **Blocked** — waiting for you (a confirmation/permission prompt). This is the one that needs your attention. - **Done** — finished and you haven't looked yet (an unseen completion). Becomes - **Idle** once you focus it. + **Idle** once you view the focused pane in the active, visible Prowl window. - **Idle** — nothing running / seen. How this is detected (process inspection + on-screen heuristics) and the full diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 33995c37a..e5b9aa3e6 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -110,11 +110,6 @@ extension WorktreeTerminalState { raw: raw ) - let seen = Self.resolvedSeen( - previous: previous, - stabilized: stabilized, - isForeground: isSelected() && isFocusedSurface(surfaceID) - ) let iconLookupToken = identified?.iconLookupToken ?? previous.iconLookupToken ?? agent.iconLookupToken let workingDirectory = activeAgentWorkingDirectory(surfaceID: surfaceID) let (session, sessionMissStreak) = await resolveRetainedSession( @@ -141,7 +136,7 @@ extension WorktreeTerminalState { iconLookupToken: iconLookupToken, fallbackState: raw, state: stabilized, - seen: seen, + seen: resolvedSeen(previous: previous, stabilized: stabilized, surfaceID: surfaceID), lastChangedAt: lastChangedAt ) next.sessionMissStreak = sessionMissStreak @@ -209,12 +204,12 @@ extension WorktreeTerminalState { return detection } - private static func resolvedSeen( + func resolvedSeen( previous: PaneAgentState, stabilized: AgentRawState, - isForeground: Bool + surfaceID: UUID ) -> Bool { - if isForeground || stabilized == .blocked { + if isViewedSurface(surfaceID) { return true } if (previous.state == .working || previous.state == .blocked) && stabilized == .idle { @@ -293,7 +288,8 @@ extension WorktreeTerminalState { } func markAgentSeen(surfaceID: UUID) { - guard var state = surfaceAgentStates[surfaceID], !state.seen else { return } + // Focus bookkeeping can run while the window is inactive or before a tab is selected. + guard isViewedSurface(surfaceID), var state = surfaceAgentStates[surfaceID], !state.seen else { return } state.seen = true state.lastChangedAt = Date() surfaceAgentStates[surfaceID] = state diff --git a/supacodeTests/WorktreeTerminalStateViewedSurfaceTests.swift b/supacodeTests/WorktreeTerminalStateViewedSurfaceTests.swift index 65c29d1c5..0530e4347 100644 --- a/supacodeTests/WorktreeTerminalStateViewedSurfaceTests.swift +++ b/supacodeTests/WorktreeTerminalStateViewedSurfaceTests.swift @@ -42,6 +42,73 @@ struct WorktreeTerminalStateViewedSurfaceTests { #expect(!state.isViewedSurface(UUID())) } + enum ViewingCondition: CaseIterable { + case viewed, inactive, hidden, unknown, canvas, otherWorktree, otherPane, otherTab + } + + @Test(arguments: ViewingCondition.allCases, [AgentRawState.working, .blocked]) + func completionReadStateRequiresViewedSurface(condition: ViewingCondition, previousState: AgentRawState) { + let (state, surfaceID) = makeViewedState() + apply(condition, to: state) + let previous = PaneAgentState(state: previousState, seen: true) + + #expect( + state.resolvedSeen(previous: previous, stabilized: .idle, surfaceID: surfaceID) == (condition == .viewed)) + } + + @Test(arguments: ViewingCondition.allCases) + func pollingAndAutomaticFocusOnlyReadViewedCompletions(condition: ViewingCondition) { + let (state, surfaceID) = makeViewedState() + apply(condition, to: state) + let done = PaneAgentState(state: .idle, seen: false) + state.surfaceAgentStates[surfaceID] = done + + #expect(state.resolvedSeen(previous: done, stabilized: .idle, surfaceID: surfaceID) == (condition == .viewed)) + state.markAgentSeen(surfaceID: surfaceID) + #expect(state.surfaceAgentStates[surfaceID]?.displayState == (condition == .viewed ? .idle : .done)) + } + + @Test func returningToViewedSurfaceAcknowledgesCompletion() { + let (state, surfaceID) = makeViewedState() + state.lastWindowIsKey = false + state.surfaceAgentStates[surfaceID] = PaneAgentState(state: .idle, seen: false) + state.markAgentSeen(surfaceID: surfaceID) + #expect(state.surfaceAgentStates[surfaceID]?.displayState == .done) + + state.lastWindowIsKey = true + state.markAgentSeen(surfaceID: surfaceID) + #expect(state.surfaceAgentStates[surfaceID]?.displayState == .idle) + } + + @Test func unviewedPollingPreservesReadStateWithoutANewCompletion() { + let (state, surfaceID) = makeViewedState() + state.lastWindowIsKey = false + let idle = PaneAgentState(state: .idle, seen: true) + #expect(state.resolvedSeen(previous: idle, stabilized: .idle, surfaceID: surfaceID)) + let unread = PaneAgentState(state: .idle, seen: false) + #expect(!state.resolvedSeen(previous: unread, stabilized: .blocked, surfaceID: surfaceID)) + } + + private func apply(_ condition: ViewingCondition, to state: WorktreeTerminalState) { + switch condition { + case .viewed: break + case .inactive: state.lastWindowIsKey = false + case .hidden: state.lastWindowIsVisible = false + case .unknown: + state.lastWindowIsKey = nil + state.lastWindowIsVisible = nil + case .canvas: state.isCanvasManaged = true + case .otherWorktree: state.isSelected = { false } + case .otherPane: + if let tabID = state.tabManager.selectedTabId { + state.focusedSurfaceIdByTab[tabID] = UUID() + } + case .otherTab: + let tabID = state.tabManager.createTab(title: "other", icon: nil) + state.tabManager.selectTab(tabID) + } + } + private func makeViewedState() -> (WorktreeTerminalState, UUID) { let state = WorktreeTerminalState( runtime: GhosttyRuntime(),