Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/components/active-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/components/agent-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion docs/components/agent-island.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions supacodeTests/WorktreeTerminalStateViewedSurfaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading