Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs-ai/066-agent-island/000-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,9 @@ until it reconnects. The picker matches by UUID only (`AgentIslandDisplaySelecti

- Updated 2026-09-05: consolidated agent presentation settings and refined the floating grip — see
[004-agent-display-settings.md](004-agent-display-settings.md).

- Updated 2026-09-08: preserve completion acknowledgements across session lookup suspension — see
[005-completion-acknowledgement.md](005-completion-acknowledgement.md).

- Updated 2026-09-08: continue Canvas completion viewing with viewport coverage — see
[006-canvas-completion-visibility.md](006-canvas-completion-visibility.md).
33 changes: 33 additions & 0 deletions docs-ai/066-agent-island/005-completion-acknowledgement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 066.005 — Completion acknowledgement across session resolution

## Context

PR #781 and the original #778, resubmitted as #782 by @SunChJ, route automatic
acknowledgement through the viewed-surface predicate. A session lookup can suspend
while the user acknowledges a completion and then leaves the window. Deriving read
state from the pre-lookup snapshot can restore Done when session metadata changes.

## Change

After session resolution, merge the current acknowledgement for the same observed
state into the polling result. Preserve its change timestamp. A new working/blocked
to idle transition must still become unread when unviewed; do not treat an earlier
acknowledgement as permission to read future completions. Keep the read policy private.

The implementation uses a per-call session resolver seam and controlled suspension
in behavior tests. Tests cover changed and unchanged session metadata, a new
completion after acknowledgement, and state changes during suspension.

If state other than acknowledgement or its timestamp changed during suspension,
discard the stale poll. The next scheduled poll observes the new state.

## Validation

The controlled suspension test failed on #781 before the fix: Done reappeared and
the acknowledgement timestamp was replaced. It passes with the fix, with and without
new session metadata. Related local tests passed; no live-agent GUI reproduction
was performed for this polling-state change.

## Refs

PR #781; related original implementation #778 and resubmission #782.
36 changes: 36 additions & 0 deletions docs-ai/066-agent-island/006-canvas-completion-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 066.006 — Canvas completion visibility

## Context and approach

Continue @SunChJ's original commit from #780, resubmitted unchanged as #783.
Keep that commit in the branch history and use #781 as the acknowledgement prerequisite.

The Canvas predicate checks actual keyboard focus and window visibility. A card can
keep those properties while panning moves it completely outside the viewport.
Reproduce that case through AppKit geometry and the completion polling path. If it
fails, require a nonempty visible area before acknowledging a completion. Partial
visibility remains sufficient; no percentage, center-point, or animation gate is added.

Preserve navigation, broadcast input, and worktree-scoped notification semantics.
Test offscreen, partially visible, scaled, and returned panes along with logical-only
focus and inactive-window cases. Verify the combined implementation with #781.

## Outcome

The combined polling test reproduced the report: an offscreen first responder changed
from Working to Idle instead of Done. Its AppKit `visibleRect` was nonempty but entirely
outside its bounds, so checking `visibleRect.isEmpty` alone would not fix the issue.
The predicate now computes the intersection of `bounds` and `visibleRect` and
requires a nonempty result. `CGRect.intersects` alone accepts a zero-area surface
in this environment, so it does not enforce the intended positive-area boundary.

The tests use positive terminal frames, real first-responder transitions, and native
view geometry. Coverage includes clipped ancestor coordinates at 0.5x, 1x, and 2x,
partial intersection, zero area, and leaving/returning to the viewport. Completion
polling and automatic acknowledgement run against the #781 implementation.

## Validation

The final combined Xcode run passed 83 test methods with zero failures and warnings.
`make check` passed, including 146 script tests. The offscreen test failed before
this change and passed afterward. No live-agent GUI session was manually exercised.
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
7 changes: 6 additions & 1 deletion docs/components/agent-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,12 @@ 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. An acknowledged completion stays read when
a pending session lookup finishes; a later unviewed completion still becomes **Done**.
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
12 changes: 12 additions & 0 deletions docs/components/canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ or the repository page otherwise.
- The left nav band is tinted with the focused card's repository color
(`windowTintMode`).

### Agent Island completion reminders

Clicking an Agent Island **Done** reminder keeps the existing Canvas navigation: Prowl comes
forward and focuses the target card and pane. The completion becomes read once that terminal
is the actual keyboard responder in its key, visible window and some of the terminal intersects
its visible viewport. A focused pane completely outside the viewport stays unread until navigation
brings it back. Partial visibility is sufficient; no minimum visible percentage is required.
A logical focus request alone,
an inactive window, or a hidden/detached terminal does not count as viewing it. Other cards
and splits are not acknowledged merely because they are selected or receive broadcast input.
**Blocked** reminders remain until the agent leaves the blocked state.

## Layout & sizing

- Default card size adapts to screen width (roughly 800×550 on a 14", larger on a
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 @@ -56,7 +56,16 @@ extension WorktreeTerminalState {
}
}

func detectAgentState(for view: GhosttySurfaceView, tabId: TerminalTabID) async -> Bool {
typealias RetainedSessionResolver =
@MainActor (
IdentifiedAgentProcess?, PaneAgentState, URL?, String, URL?
) async -> (session: AgentSession?, missStreak: Int)

func detectAgentState(
for view: GhosttySurfaceView,
tabId: TerminalTabID,
resolveSession: RetainedSessionResolver? = nil
) async -> Bool {
let surfaceID = view.id
let childPID = view.bridge.childPID()
let processGroupID = view.bridge.foregroundProcessGroupID()
Expand Down Expand Up @@ -94,7 +103,7 @@ extension WorktreeTerminalState {
}

let now = Date()
let previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now)
var previous = surfaceAgentStates[surfaceID] ?? PaneAgentState(lastChangedAt: now)
let activeText = view.bridge.readActiveText() ?? ""
// Reuse the previous scan while the screen and detected agent are unchanged.
// A live-but-idle agent is polled every 300 ms and `detectState` re-splits,
Expand All @@ -110,24 +119,22 @@ 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(
identified: identified,
previous: previous,
workingDirectory: workingDirectory,
activeText: activeText,
configRoot: launchProfilesBySurface[surfaceID]?.configRoot(forDetected: agent)
let (session, sessionMissStreak) = await (resolveSession ?? Self.resolveRetainedSession)(
identified, previous, workingDirectory, activeText,
launchProfilesBySurface[surfaceID]?.configRoot(forDetected: agent)
)
// Re-check after the suspension: the pane may have been closed and its
// agent state cleaned up while the resolver was doing file inspection;
// writing below would resurrect a ghost Active Agents entry.
guard surfaces[surfaceID] != nil else { return false }
// Acknowledgement can change while session inspection is suspended. Preserve it,
// but discard this observation if any other state changed in the meantime.
guard let current = surfaceAgentStates[surfaceID] else { return false }
previous.seen = current.seen
previous.lastChangedAt = current.lastChangedAt
guard previous == current else { return true }
let launchObservation = resolvedLaunchObservation(identified: identified, previous: previous)
let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt
var next = PaneAgentState(
Expand All @@ -141,7 +148,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 +216,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 {
Expand Down Expand Up @@ -268,7 +275,7 @@ extension WorktreeTerminalState {
)
}

private func resolveRetainedSession(
private static func resolveRetainedSession(
identified: IdentifiedAgentProcess?,
previous: PaneAgentState,
workingDirectory: URL?,
Expand All @@ -293,7 +300,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
Original file line number Diff line number Diff line change
Expand Up @@ -696,10 +696,21 @@ extension WorktreeTerminalState {
return isSelected() && lastWindowIsKey == true && lastWindowIsVisible == true
}

/// Whether the user is actively looking at `surfaceId` right now: its worktree
/// is visible and it is the focused pane of the selected tab.
/// Whether the user is actively looking at `surfaceId` right now.
func isViewedSurface(_ surfaceId: UUID) -> Bool {
isViewingWorktree() && isFocusedSurface(surfaceId)
if isCanvasManaged {
// Canvas owns focus separately from normal-mode selection and window observers.
// Its logical focus callback runs before requestFocus actually installs the responder.
// Panning preserves focus. visibleRect can extend outside bounds on non-clipping views.
guard isFocusedSurface(surfaceId), let view = surfaces[surfaceId],
view.focused, !view.isHiddenOrHasHiddenAncestor,
let window = view.window
else { return false }
let visibleBounds = view.bounds.intersection(view.visibleRect)
return !visibleBounds.isEmpty && window.isKeyWindow && window.occlusionState.contains(.visible)
&& window.firstResponder === view
}
return isViewingWorktree() && isFocusedSurface(surfaceId)
}

func updateRunningState(for tabId: TerminalTabID) {
Expand Down
Loading
Loading