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..90c4a3396 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( + private 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..68473c8ef 100644 --- a/supacodeTests/WorktreeTerminalStateViewedSurfaceTests.swift +++ b/supacodeTests/WorktreeTerminalStateViewedSurfaceTests.swift @@ -1,4 +1,5 @@ import Foundation +import GhosttyKit import Testing @testable import supacode @@ -42,6 +43,129 @@ struct WorktreeTerminalStateViewedSurfaceTests { #expect(!state.isViewedSurface(UUID())) } + @Test func inactiveFocusedCompletionPollRemainsUnread() async { + let fixture = makeDetectionFixture() + fixture.state.lastWindowIsKey = false + fixture.state.surfaceAgentStates[fixture.surface.id] = PaneAgentState( + detectedAgent: .claude, + state: .working, + seen: true + ) + fixture.state.agentDetectionPresenceBySurface[fixture.surface.id] = AgentDetectionPresence( + currentAgent: .claude + ) + fixture.state.lastAgentScreenScanBySurface[fixture.surface.id] = WorktreeTerminalState.AgentScreenScan( + agent: .claude, + text: "", + detection: AgentScreenDetection(state: .idle, reason: .legacyDetector) + ) + + #expect(await fixture.state.detectAgentState(for: fixture.surface, tabId: fixture.tabID)) + #expect(fixture.state.surfaceAgentStates[fixture.surface.id]?.displayState == .done) + } + + @Test func inactiveFocusBookkeepingDoesNotAcknowledgeCompletion() { + let fixture = makeDetectionFixture() + fixture.state.lastWindowIsKey = false + fixture.state.surfaceAgentStates[fixture.surface.id] = PaneAgentState( + detectedAgent: .claude, + state: .idle, + seen: false + ) + + fixture.state.recordActiveSurface(fixture.surface, in: fixture.tabID) + + #expect(fixture.state.surfaceAgentStates[fixture.surface.id]?.displayState == .done) + } + + enum ViewingCondition: CaseIterable { + case viewed, inactive, hidden, unknown, canvas, otherWorktree, otherPane, otherTab + } + + @Test(arguments: ViewingCondition.allCases, [AgentRawState.working, .blocked]) + func completionPollingRequiresViewedSurface( + condition: ViewingCondition, + previousState: AgentRawState + ) async { + let fixture = makeDetectionFixture() + apply(condition, to: fixture.state) + preparePoll( + fixture, + previous: PaneAgentState(detectedAgent: .claude, state: previousState, seen: true), + detected: .idle + ) + + #expect(await fixture.state.detectAgentState(for: fixture.surface, tabId: fixture.tabID)) + #expect( + fixture.state.surfaceAgentStates[fixture.surface.id]?.displayState + == (condition == .viewed ? .idle : .done) + ) + } + + @Test(arguments: ViewingCondition.allCases) + func automaticAcknowledgementRequiresViewedSurface(condition: ViewingCondition) { + let fixture = makeDetectionFixture() + apply(condition, to: fixture.state) + fixture.state.surfaceAgentStates[fixture.surface.id] = PaneAgentState( + detectedAgent: .claude, + state: .idle, + seen: false + ) + + fixture.state.markAgentSeen(surfaceID: fixture.surface.id) + + #expect( + fixture.state.surfaceAgentStates[fixture.surface.id]?.displayState + == (condition == .viewed ? .idle : .done) + ) + } + + @Test func inactiveStableIdlePollDoesNotCreateCompletion() async { + let fixture = makeDetectionFixture() + fixture.state.lastWindowIsKey = false + preparePoll( + fixture, + previous: PaneAgentState(detectedAgent: .claude, state: .idle, seen: true), + detected: .idle + ) + + #expect(await fixture.state.detectAgentState(for: fixture.surface, tabId: fixture.tabID)) + #expect(fixture.state.surfaceAgentStates[fixture.surface.id]?.displayState == .idle) + } + + @Test func inactiveUnreadStateSurvivesBlockedRoundTrip() async { + let fixture = makeDetectionFixture() + fixture.state.lastWindowIsKey = false + preparePoll( + fixture, + previous: PaneAgentState(detectedAgent: .claude, state: .idle, seen: false), + detected: .blocked + ) + + #expect(await fixture.state.detectAgentState(for: fixture.surface, tabId: fixture.tabID)) + #expect(fixture.state.surfaceAgentStates[fixture.surface.id]?.seen == false) + + prepareDetection(fixture, detected: .idle) + #expect(await fixture.state.detectAgentState(for: fixture.surface, tabId: fixture.tabID)) + #expect(fixture.state.surfaceAgentStates[fixture.surface.id]?.displayState == .done) + } + + @Test func returningToViewedSurfaceAcknowledgesCompletion() { + let fixture = makeDetectionFixture() + fixture.state.lastWindowIsKey = false + fixture.state.surfaceAgentStates[fixture.surface.id] = PaneAgentState( + detectedAgent: .claude, + state: .idle, + seen: false + ) + fixture.state.markAgentSeen(surfaceID: fixture.surface.id) + #expect(fixture.state.surfaceAgentStates[fixture.surface.id]?.displayState == .done) + + fixture.state.lastWindowIsKey = true + fixture.state.markAgentSeen(surfaceID: fixture.surface.id) + #expect(fixture.state.surfaceAgentStates[fixture.surface.id]?.displayState == .idle) + } + private func makeViewedState() -> (WorktreeTerminalState, UUID) { let state = WorktreeTerminalState( runtime: GhosttyRuntime(), @@ -62,4 +186,84 @@ struct WorktreeTerminalStateViewedSurfaceTests { state.lastWindowIsVisible = true return (state, surfaceId) } + + private struct DetectionFixture { + let state: WorktreeTerminalState + let tabID: TerminalTabID + let surface: GhosttySurfaceView + } + + private func makeDetectionFixture() -> DetectionFixture { + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: Worktree( + id: "/tmp/repo/wt-1", + name: "wt-1", + detail: "", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + ) + let surface = GhosttySurfaceView( + runtime: state.runtime, + workingDirectory: state.worktree.workingDirectory, + fontSize: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + skipsSurfaceCreationForTesting: true + ) + let tabID = state.tabManager.createTab(title: "tab", icon: nil) + state.tabManager.selectTab(tabID) + state.surfaces[surface.id] = surface + state.trees[tabID] = SplitTree(view: surface) + state.focusedSurfaceIdByTab[tabID] = surface.id + state.isSelected = { true } + state.lastWindowIsKey = true + state.lastWindowIsVisible = true + return DetectionFixture(state: state, tabID: tabID, surface: surface) + } + + 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 preparePoll( + _ fixture: DetectionFixture, + previous: PaneAgentState, + detected: AgentRawState + ) { + fixture.state.surfaceAgentStates[fixture.surface.id] = previous + fixture.state.agentDetectionPresenceBySurface[fixture.surface.id] = AgentDetectionPresence( + currentAgent: .claude + ) + prepareDetection(fixture, detected: detected) + } + + private func prepareDetection(_ fixture: DetectionFixture, detected: AgentRawState) { + fixture.state.lastAgentScreenScanBySurface[fixture.surface.id] = WorktreeTerminalState.AgentScreenScan( + agent: .claude, + text: "", + detection: AgentScreenDetection(state: detected, reason: .legacyDetector) + ) + } }