diff --git a/.claude/rules/menu-actions.md b/.claude/rules/menu-actions.md index 70ebc8a8c..959346cd1 100644 --- a/.claude/rules/menu-actions.md +++ b/.claude/rules/menu-actions.md @@ -228,9 +228,12 @@ paths: - An empty query skips ranking in two cases: attention mode and a caller-supplied picker. Both keep their source order, because every row scores 0 and the tie-break would re-sort A→Z and replace the row Return runs. Every other palette lists everything A→Z. -- Attention mode lists every non-idle session, ordered blocked, active, completed and then newest - `statusChangedAt`, with nil last. Palette items carry status plus per-call color/shape, resolved by the - same helpers as sidebar glyphs. Typed queries use fuzzy score. +- Attention mode lists every open window's non-idle sessions (`WindowLibrary.attentionAcrossWindows`), + ordered blocked, active, completed and then newest `statusChangedAt`, with nil last, as one combined + sort. Palette items carry status plus per-call color/shape, resolved by the same helpers as sidebar + glyphs, a subtitle naming the window once more than one is open, and `isEnabled` asking the OWNING + window's modal gate. A pick defers `AppActions.selectAttention` past the palette's close, which raises + another window before selecting. Typed queries use fuzzy score. - Open attention through `show_attention` (Ctrl-Shift-I), Navigate > Go to Attention, or Show Attention in the action palette. The titlebar bell opens a popover, not this palette. Palette opening is keep-in-sync exempt. @@ -252,7 +255,8 @@ paths: background, and chrome text. - Clock lists up to `maxCandidates` recent visible sessions excluding active and enables only with at least two sessions. Selection records activity, selects, and focuses. -- Bell lists all non-idle sessions including current. Selection uses pane-aware reveal. +- Bell lists all non-idle sessions across open windows, current included. Selection uses the same + pane-aware reveal as the palette; see [[notifications]] for the cross-window raise. - Popover opens are keep-in-sync exempt. Synthesized XCUITest clicks inside `NSPopover` do not fire the SwiftUI button, though real clicks do; tests verify open/list contents, while selection is manual plus host-free API coverage. diff --git a/.claude/rules/notifications.md b/.claude/rules/notifications.md index b903ef755..25e89f208 100644 --- a/.claude/rules/notifications.md +++ b/.claude/rules/notifications.md @@ -146,13 +146,21 @@ paths: ## Titlebar attention - With `attentionButtonEnabled` off by default, `customTitlebar` places a bell after recent sessions and - before scratch/split/quick-terminal controls. It derives live state from all non-idle - `AppStore.attentionSessions`: empty is disabled `bell` at about 0.35 opacity; non-blocked is enabled - `bell` in `chromeText`; any blocked is enabled `bell.fill` in `blockedStatusColor`. There is no count - or pulse. + before scratch/split/quick-terminal controls. It derives live state from + `WindowLibrary.attentionAcrossWindows`, every open window's non-idle sessions in one + `AppStore.attentionPrecedes` order: empty is disabled `bell` at about 0.35 opacity; non-blocked is + enabled `bell` in `chromeText`; any blocked is enabled `bell.fill` in `blockedStatusColor`. There is no + count or pulse. That getter reads a private open-set version the library bumps on every store load and + drop, because `stores` is observation-ignored and a background window closing changes neither + `windows` nor `frontmostWindowID`. - Clicking opens the mouse popover of `SessionPopoverRow`s with `StatusGlyph`, ordered - blocked, active, completed; selection reveals the tagged blocked pane. Ctrl-Shift-I, Navigate > Go to - Attention, and Show Attention in the palette retain the searchable keyboard surface. + blocked, active, completed, subtitled by `attentionSubtitle` (window name first once more than one + window is open). Selection closes the popover, then `AppActions.selectAttention` on the next turn: + it rechecks the owning window's modal gate, raises it through `WindowRegistry.raise` plus + `takeFrontmost` when it is not the active one, and reveals the tagged blocked pane through + `revealActiveBlockedPane` exactly as the per-window path does. Ctrl-Shift-I, Navigate > Go to + Attention, and Show Attention in the palette retain the searchable keyboard surface over the same + list. The Dock menu stays scoped to its captured window. - The button ID is `attention-button`, with help and value `none`, `attention`, or `blocked`. `WindowContentView` mirrors `GhosttyApp.attentionButtonEnabled` into state and refreshes on `.agtermAppearanceChanged`, not `model.settings`. This mouse form of controllable attention selection is diff --git a/agterm/AppActions+Focus.swift b/agterm/AppActions+Focus.swift index 4763ef815..190ebb8b5 100644 --- a/agterm/AppActions+Focus.swift +++ b/agterm/AppActions+Focus.swift @@ -139,6 +139,27 @@ extension AppActions { } } + /// Whether an attention row can be acted on right now: its window open and not under a cover, its + /// session still there. Asked at render and again at the pick, since a row outlives all three. + func canSelectAttention(windowID: WindowInfo.ID, sessionID: UUID) -> Bool { + uiActionsEnabled(for: windowID) && library.store(for: windowID)?.session(withID: sessionID) != nil + } + + /// Select a row of the cross-window attention list and reveal its pane, raising its window first when it + /// is not the active one so `store` resolves there. A raise that fails (the window still attaching) + /// drops the pick as a window step does. + func selectAttention(windowID: WindowInfo.ID, sessionID: UUID) { + guard canSelectAttention(windowID: windowID, sessionID: sessionID), + let target = library.store(for: windowID) else { return } + if windowID != library.activeWindowID { + guard WindowRegistry.shared.raise(windowID) else { return } + takeFrontmost(windowID) + } + target.noteUserActivity() + let indicator = target.selectSession(sessionID) + revealActiveBlockedPane(captured: indicator) + } + /// Front and focus the window a recent-closed reopen restored into. The id is published here rather /// than left to the key-window report, which `focusActiveSession` would otherwise outrun; publishing it /// also has to save and post, because `WindowAccessor.reportFrontmost` gates both on the id having diff --git a/agterm/AppActions+Navigation.swift b/agterm/AppActions+Navigation.swift index fe1bc2471..d0b132165 100644 --- a/agterm/AppActions+Navigation.swift +++ b/agterm/AppActions+Navigation.swift @@ -62,7 +62,7 @@ extension AppActions { /// `didBecomeKey` and a step from the quick terminal raises with agterm INACTIVE — leaving the id stale, /// so every later step recomputes from the same origin. The control twin omits the post below, its /// dispatch refreshing that cache inline. - private func takeFrontmost(_ id: WindowInfo.ID) { + func takeFrontmost(_ id: WindowInfo.ID) { guard library.frontmostWindowID != id else { return } library.frontmostWindowID = id library.saveIndex() diff --git a/agterm/AppActions+Palette.swift b/agterm/AppActions+Palette.swift index f37192399..d37255c49 100644 --- a/agterm/AppActions+Palette.swift +++ b/agterm/AppActions+Palette.swift @@ -225,27 +225,34 @@ extension AppActions { return store.navigableSessions.map { paletteItem(for: $0, in: store) } } - /// The window's non-idle sessions as palette items (`.attention` mode), each row carrying the session's - /// agent-status glyph. `store.attentionSessions` orders blocked→active→completed, newest status-change - /// first, so the empty-query order matches; choosing one selects it. Subtitle as in `paletteSessions()`. + /// Every open window's non-idle sessions as palette items (`.attention` mode), in the library's order so + /// the empty query keeps it. Enablement asks the OWNING window, not the frontmost one, and the run + /// defers `selectAttention` past the palette's close and focus-restore so a raise never competes with + /// the dismissal. func paletteAttention() -> [PaletteItem] { - guard let store else { return [] } - return store.attentionSessions.map { - paletteItem(for: $0, in: store, status: $0.agentIndicator.status, - statusColor: $0.agentIndicator.color, statusShape: $0.agentIndicator.shape) + library.attentionAcrossWindows.map { entry in + let windowID = entry.window.id + let sessionID = entry.session.id + let indicator = entry.session.agentIndicator + return PaletteItem(id: sessionID.uuidString, title: entry.session.displayName, + subtitle: library.attentionSubtitle(entry), status: indicator.status, + statusColor: indicator.color, statusShape: indicator.shape, + isEnabled: { [weak self] in + self?.canSelectAttention(windowID: windowID, sessionID: sessionID) ?? false + }, + run: { [weak self] in + DispatchQueue.main.async { self?.selectAttention(windowID: windowID, sessionID: sessionID) } + }) } } /// Maps one session to a palette row — title `displayName`, subtitle "`workspace` · `subtitleDetail`", run - /// selects it. Shared by `paletteSessions()` (status nil) and `paletteAttention()`, where a set status makes - /// `CommandPalette.row` render the leading `StatusGlyph` in the per-call `statusColor`/`statusShape`. - private func paletteItem(for session: Session, in store: AppStore, status: AgentStatus? = nil, - statusColor: String? = nil, statusShape: StatusShape? = nil) -> PaletteItem { + /// selects it. + private func paletteItem(for session: Session, in store: AppStore) -> PaletteItem { let id = session.id let workspaceName = store.workspace(forSession: id)?.name ?? "" let subtitle = "\(workspaceName) · \(session.subtitleDetail)" - return PaletteItem(id: id.uuidString, title: session.displayName, subtitle: subtitle, - status: status, statusColor: statusColor, statusShape: statusShape) { [weak self] in + return PaletteItem(id: id.uuidString, title: session.displayName, subtitle: subtitle) { [weak self] in guard self?.uiActionsEnabled == true else { return } // a palette pick is user-initiated: note activity so it buys the full idle grace before // auto-follow can pull the selection back. diff --git a/agterm/Views/SessionSwitcher.swift b/agterm/Views/SessionSwitcher.swift index 7467d675a..60b5b2648 100644 --- a/agterm/Views/SessionSwitcher.swift +++ b/agterm/Views/SessionSwitcher.swift @@ -216,8 +216,8 @@ struct SessionSwitcherOverlay: View { } /// Carries the measured row-stack height out of the scroll content, so the panel can size to its rows -/// instead of to the height it is offered. -private struct RowsHeightKey: PreferenceKey { +/// instead of to the height it is offered. Shared with the title-bar attention popover. +struct RowsHeightKey: PreferenceKey { static let defaultValue: Double = 0 static func reduce(value: inout Double, nextValue: () -> Double) { value = max(value, nextValue()) } } diff --git a/agterm/Views/WindowContentView+RecentSessions.swift b/agterm/Views/WindowContentView+RecentSessions.swift index b7a6ea3a3..4083d5ee0 100644 --- a/agterm/Views/WindowContentView+RecentSessions.swift +++ b/agterm/Views/WindowContentView+RecentSessions.swift @@ -106,18 +106,17 @@ extension WindowContentView { recentSessionsShown = false } - /// Title-bar bell reflecting the window's attention state (opt-in, gated by the `attentionButtonEnabled` - /// mirror). Three states from `store.attentionSessions`: empty → a dimmed disabled outline bell; non-empty - /// with nothing blocked → a plain enabled bell in `chromeText`; any blocked session → a filled bell tinted - /// the blocked-status color. No count, no pulse. Click opens the attention popover (the mouse form; ⌃⇧I / - /// the Navigate menu keep the searchable `.attention` palette). Reading `store.attentionSessions` in the - /// body registers the per-session `agentIndicator` observation, so the glyph re-renders live; - /// `.accessibilityValue` (none|attention|blocked) exposes the otherwise-unobservable bell↔bell.fill state - /// to XCUITest, mirroring `StatusIconView`. + /// Title-bar bell reflecting the attention state across every open window (opt-in, gated by the + /// `attentionButtonEnabled` mirror): dimmed and disabled when empty, plain when nothing is blocked, filled + /// in the blocked-status color otherwise. No count, no pulse. Click opens the attention popover (the + /// mouse form; ⌃⇧I / the Navigate menu keep the searchable `.attention` palette). Reading the library + /// list in the body registers the per-session `agentIndicator` observation and the open-set version, so + /// the glyph re-renders live; `.accessibilityValue` (none|attention|blocked) exposes the + /// otherwise-unobservable bell↔bell.fill state to XCUITest, mirroring `StatusIconView`. var attentionButton: some View { - let sessions = store.attentionSessions - let blocked = sessions.contains { $0.agentIndicator.status == .blocked } - let empty = sessions.isEmpty + let entries = library.attentionAcrossWindows + let blocked = entries.contains { $0.session.agentIndicator.status == .blocked } + let empty = entries.isEmpty let enabled = !empty && !pick.modalPending return Button { guard !pick.modalPending else { return } @@ -145,39 +144,59 @@ extension WindowContentView { } } - /// The attention popover body: the window's sessions needing attention (`store.attentionSessions`, sorted - /// blocked→active→completed) as full-row `SessionPopoverRow`s with a leading status glyph — the mouse form - /// of the ⌃⇧I attention palette, tinted and hover-highlighted like the recent-sessions popover. Clicking a - /// row selects the session and reveals its blocked pane. + /// The attention popover body: every open window's sessions needing attention as full-row + /// `SessionPopoverRow`s with a leading status glyph — the mouse form of the ⌃⇧I attention palette. A row + /// whose window sits under a cover renders disabled, as the palette's does, rather than dismissing into + /// a no-op. The rows are measured so short lists stay compact and long lists scroll at + /// `attentionRowsCap`, for the reason `measuredPanelHeight` gives. private var attentionPopover: some View { - VStack(spacing: 2) { - ForEach(store.attentionSessions) { session in - SessionPopoverRow( - title: session.displayName, - subtitle: "\(store.workspace(forSession: session.id)?.name ?? "") · \(session.subtitleDetail)", - status: session.agentIndicator.status, - statusColorHex: session.agentIndicator.color, - statusShape: session.agentIndicator.shape, - foreground: chromeText, - hoverColor: popoverHoverColor, - accessibilityID: "attention-session-row" - ) { selectAttention(session.id) } + let metrics = GhosttyApp.shared.interfaceMetrics + return ScrollView { + VStack(spacing: 2) { + ForEach(library.attentionAcrossWindows) { entry in + SessionPopoverRow( + title: entry.session.displayName, + subtitle: library.attentionSubtitle(entry), + status: entry.session.agentIndicator.status, + statusColorHex: entry.session.agentIndicator.color, + statusShape: entry.session.agentIndicator.shape, + foreground: chromeText, + hoverColor: popoverHoverColor, + accessibilityID: "attention-session-row", + isEnabled: actions.canSelectAttention(windowID: entry.window.id, sessionID: entry.session.id) + ) { selectAttention(entry) } + } } + .background( + GeometryReader { rows in + Color.clear.preference(key: RowsHeightKey.self, value: rows.size.height) + } + ) + } + .frame(height: metrics.measuredPanelHeight(rowsHeight: attentionRowsHeight, + maxRowsHeight: metrics.scaled(Self.attentionRowsCap)).map { CGFloat($0) }) + .scrollBounceBehavior(.basedOnSize) + .onPreferenceChange(RowsHeightKey.self) { height in + attentionRowsHeight = height } .padding(6) - .frame(width: GhosttyApp.shared.interfaceMetrics.scaled(320)) + .frame(width: metrics.scaled(320)) .background(terminalColor) .presentationBackground(terminalColor) } - /// Commit an attention popover row click: select the session and reveal its blocked pane (the pane that - /// set the status), then close the popover — the mouse twin of the ⌃⇧I palette's select-and-reveal. - private func selectAttention(_ id: UUID) { - guard !pick.modalPending else { return } - store.noteUserActivity() - let indicator = store.selectSession(id) - actions.revealActiveBlockedPane(captured: indicator) + /// The attention popover's row-stack cap at the default interface size, about ten rows. + static let attentionRowsCap: Double = 440 + + /// Commit an attention popover row click. The popover closes first and the select runs on the next turn, + /// so a raise of another window never competes with this popover's dismissal; the action rechecks the + /// target then. + private func selectAttention(_ entry: AttentionEntry) { + let windowID = entry.window.id + let sessionID = entry.session.id + guard !pick.modalPending, actions.canSelectAttention(windowID: windowID, sessionID: sessionID) else { return } attentionPopoverShown = false + DispatchQueue.main.async { actions.selectAttention(windowID: windowID, sessionID: sessionID) } } } @@ -187,6 +206,7 @@ extension WindowContentView { /// the row matches the sidebar glyph), a pointer-hover highlight (`hoverColor`) and a full-row hit area /// (`.contentShape`), so the WHOLE row selects on click, not just the text. Kept a `Button` so it reads as an /// actionable control to VoiceOver; `accessibilityID` distinguishes the two popovers' rows for the tests. +/// `isEnabled` false renders the row dimmed and inert, for an attention row whose window is under a cover. private struct SessionPopoverRow: View { let title: String let subtitle: String @@ -196,6 +216,7 @@ private struct SessionPopoverRow: View { let foreground: Color let hoverColor: Color let accessibilityID: String + var isEnabled = true let onSelect: () -> Void @State private var hovering = false @@ -203,11 +224,13 @@ private struct SessionPopoverRow: View { Button(action: onSelect) { SessionSwitcherRow(title: title, subtitle: subtitle, foreground: foreground, status: status, statusColorHex: statusColorHex, statusShape: statusShape) - .background(hovering ? hoverColor : Color.clear) + .background(hovering && isEnabled ? hoverColor : Color.clear) .clipShape(RoundedRectangle(cornerRadius: 6)) .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.35) .onHover { hovering = $0 } .accessibilityIdentifier(accessibilityID) } diff --git a/agterm/Views/WindowContentView.swift b/agterm/Views/WindowContentView.swift index 5bee10d06..7f271e924 100644 --- a/agterm/Views/WindowContentView.swift +++ b/agterm/Views/WindowContentView.swift @@ -87,6 +87,9 @@ struct WindowContentView: View { /// Whether the attention popover (the mouse equivalent of the ⌃⇧I attention palette) is shown, anchored /// on the title-bar bell. Non-private so the `+RecentSessions` extension's bell/rows can toggle it. @State var attentionPopoverShown = false + /// The attention popover's measured row-stack height, 0 until its preference lands; the popover sizes to + /// it up to a cap so a long cross-window list scrolls instead of running off the screen. + @State var attentionRowsHeight: Double = 0 /// Whether the custom-commands popover (the mouse form of the ⌃⇧O palette) is shown, anchored on its /// title-bar button. Non-private so the `+CustomCommands` extension's button/rows can toggle it. @State var customCommandsShown = false diff --git a/agtermCore/Sources/agtermCore/AppStore+Status.swift b/agtermCore/Sources/agtermCore/AppStore+Status.swift index 826876d78..db034a78c 100644 --- a/agtermCore/Sources/agtermCore/AppStore+Status.swift +++ b/agtermCore/Sources/agtermCore/AppStore+Status.swift @@ -74,24 +74,26 @@ extension AppStore { ) } - /// The window-wide non-idle sessions, the single source of truth for the titlebar attention icon and the - /// `.attention` palette. Spans ALL workspaces (`workspaces.flatMap(\.sessions)`) and deliberately IGNORES - /// the focus/flagged sidebar filter (unlike `navigableSessions`) — the point is window-wide visibility even - /// when the sidebar is hidden. Sorted by `attentionRank` ascending (blocked → active → completed) then - /// `statusChangedAt` DESCENDING (newest first; a nil stamp sorts last within its rank group). + /// The window's non-idle sessions across ALL workspaces, ignoring the focus/flagged sidebar filter + /// (unlike `navigableSessions`) so they stay visible with the sidebar hidden. Ordered by `attentionPrecedes`. public var attentionSessions: [Session] { workspaces.flatMap(\.sessions) .filter { $0.agentIndicator.status != .idle } - .sorted { lhs, rhs in - let lrank = lhs.agentIndicator.status.attentionRank - let rrank = rhs.agentIndicator.status.attentionRank - if lrank != rrank { return lrank < rrank } - switch (lhs.statusChangedAt, rhs.statusChangedAt) { - case let (l?, r?): return l > r // newest change first within the rank group - case (_?, nil): return true // a stamped session sorts before an unstamped one - case (nil, _?): return false - case (nil, nil): return false - } - } + .sorted(by: Self.attentionPrecedes) + } + + /// The attention order every attention list shares, so the per-window and cross-window lists cannot + /// drift: `attentionRank` ascending (blocked → active → completed), then `statusChangedAt` DESCENDING + /// (newest first; a nil stamp sorts last within its rank group). + public static func attentionPrecedes(_ lhs: Session, _ rhs: Session) -> Bool { + let lrank = lhs.agentIndicator.status.attentionRank + let rrank = rhs.agentIndicator.status.attentionRank + if lrank != rrank { return lrank < rrank } + switch (lhs.statusChangedAt, rhs.statusChangedAt) { + case let (l?, r?): return l > r + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): return false + } } } diff --git a/agtermCore/Sources/agtermCore/WindowLibrary+Attention.swift b/agtermCore/Sources/agtermCore/WindowLibrary+Attention.swift new file mode 100644 index 000000000..bf65865b4 --- /dev/null +++ b/agtermCore/Sources/agtermCore/WindowLibrary+Attention.swift @@ -0,0 +1,34 @@ +import Foundation + +/// One row of the cross-window attention list: a non-idle session and the open window it lives in. +public struct AttentionEntry: Identifiable { + public let window: WindowInfo + public let session: Session + + public var id: UUID { session.id } +} + +/// The cross-window attention feed behind the titlebar bell and the `.attention` palette, split out of +/// `WindowLibrary.swift` for the file-length limit. +extension WindowLibrary { + /// Every open window's non-idle sessions in one `AppStore.attentionPrecedes` order, each paired with + /// its window. Equals the active store's `attentionSessions` with one window open. Reads + /// `openSetVersion` so an observer re-fires when a background window opens or closes, not only when a + /// session's status changes. + public var attentionAcrossWindows: [AttentionEntry] { + _ = openSetVersion + return windows + .compactMap { info in stores[info.id].map { (info, $0) } } + .flatMap { info, store in store.attentionSessions.map { AttentionEntry(window: info, session: $0) } } + .sorted { AppStore.attentionPrecedes($0.session, $1.session) } + } + + /// The row subtitle every attention surface shares: "`workspace` · detail" as the per-window lists + /// read, led by the window name once more than one window is open, since only then does a row need + /// to say where it lives. + public func attentionSubtitle(_ entry: AttentionEntry) -> String { + let workspace = stores[entry.window.id]?.workspace(forSession: entry.session.id)?.name ?? "" + let detail = "\(workspace) · \(entry.session.subtitleDetail)" + return openIDs().count > 1 ? "\(entry.window.name) · \(detail)" : detail + } +} diff --git a/agtermCore/Sources/agtermCore/WindowLibrary.swift b/agtermCore/Sources/agtermCore/WindowLibrary.swift index 18261b0fe..b80251b25 100644 --- a/agtermCore/Sources/agtermCore/WindowLibrary.swift +++ b/agtermCore/Sources/agtermCore/WindowLibrary.swift @@ -79,8 +79,15 @@ public final class WindowLibrary { /// "none open"; sites that only compare or reassign the raw id are unaffected either way. public var frontmostWindowID: UUID? - /// Live per-window stores. `@ObservationIgnored`: read imperatively (scene/control), never by a view. - @ObservationIgnored private var stores: [UUID: AppStore] + /// Live per-window stores. `@ObservationIgnored`: read imperatively (scene/control); a view reaches + /// them only through `store(for:)`, `isOpen` and `attentionAcrossWindows`, whose `openSetVersion` read + /// carries the observation. + @ObservationIgnored var stores: [UUID: AppStore] + + /// Bumped on every open-set change, the observable stand-in for `stores` membership: a background + /// window opening or closing changes neither `windows` nor (necessarily) `frontmostWindowID`, so a + /// view reading the attention list or a row's eligibility would otherwise keep its stale answer. + private(set) var openSetVersion = 0 /// The state directory (AGTERM_STATE_DIR-aware): the index here, per-window files in `windows/`. @ObservationIgnored private let directory: URL @@ -148,7 +155,10 @@ public final class WindowLibrary { // MARK: - Lookup + /// The open window's store, nil for a closed or unknown id. Reads `openSetVersion` so a view deciding + /// on membership re-renders when a background window opens or closes. public func store(for id: UUID?) -> AppStore? { + _ = openSetVersion guard let id else { return nil } return stores[id] } @@ -169,7 +179,7 @@ public final class WindowLibrary { } public func isOpen(_ id: UUID) -> Bool { - stores[id] != nil + store(for: id) != nil } /// Auto-hide-inactive-sidebars driver: the frontmost open window shows its sidebar, every OTHER open one @@ -368,6 +378,7 @@ public final class WindowLibrary { store.addSession(toWorkspace: workspace.id, cwd: FileManager.default.homeDirectoryForCurrentUser.path) windows.append(info) stores[info.id] = store + openSetVersion += 1 // mark frontmost now so the window-keyed seams target it immediately instead of waiting on its // first `didBecomeKey` — which loses to the File-menu focus returning to the previous window. frontmostWindowID = info.id @@ -391,6 +402,7 @@ public final class WindowLibrary { let snapshot = loadSnapshotForStore(persistence) store.restore(from: snapshot, launchRestore: launchRestore) stores[id] = store + openSetVersion += 1 let carriedCaptures = snapshot.workspaces.contains { workspace in workspace.sessions.contains { $0.foregroundCommand != nil || $0.splitForegroundCommand != nil } } @@ -461,6 +473,7 @@ public final class WindowLibrary { } store.scheduleTreeChanged() stores[id] = nil + openSetVersion += 1 // the persisted `frontmost` is what the next launch's `reopen` fallback picks, and nil there // sends it to `windows.first`. Pin unconditionally on the close that empties the open set, so a // frontmost left nil or stale by `removeWindow` still reopens the exit window; otherwise hand it @@ -520,6 +533,7 @@ public final class WindowLibrary { WatermarkStorage.removeRenderedText(sessionID: sessionID, stateDir: directory) } stores[id] = nil + openSetVersion += 1 windows.remove(at: index) if frontmostWindowID == id { frontmostWindowID = nil } // best-effort: a missing/never-written per-window file is fine to "fail" to remove. @@ -686,6 +700,7 @@ public final class WindowLibrary { store.save() windows = [info] stores[info.id] = store + openSetVersion += 1 frontmostWindowID = info.id saveIndex() return true diff --git a/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift b/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift index 6c635292d..66fe03737 100644 --- a/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift +++ b/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift @@ -1814,4 +1814,103 @@ final class WindowLibraryTests { #expect(Set(log.identities) == expected) #expect(!expected.isEmpty) } + + private func addSession(to store: AppStore, status: AgentStatus) throws -> Session { + let session = try #require(store.addSession(toWorkspace: store.workspaces[0].id, cwd: "/tmp")) + store.setAgentIndicator(AgentIndicator(status: status), forSession: session.id) + return session + } + + @Test func attentionAcrossWindowsRanksStatusBeforeWindowOrder() throws { + let library = WindowLibrary(directory: directory) + let first = try #require(library.activeWindowID) + let firstStore = try #require(library.store(for: first)) + let second = library.newWindow(name: "second") + let secondStore = try #require(library.store(for: second.id)) + let blocked = try addSession(to: secondStore, status: .blocked) + let completed = try addSession(to: firstStore, status: .completed) + let active = try addSession(to: firstStore, status: .active) + + let entries = library.attentionAcrossWindows + + #expect(entries.map(\.session.id) == [blocked.id, active.id, completed.id]) + #expect(entries.map(\.window.id) == [second.id, first, first]) + } + + @Test func attentionAcrossWindowsOrdersASharedRankNewestFirstAcrossWindows() throws { + let library = WindowLibrary(directory: directory) + let firstStore = try #require(library.activeStore) + let second = library.newWindow(name: "second") + let secondStore = try #require(library.store(for: second.id)) + let older = try addSession(to: firstStore, status: .blocked) + let newer = try addSession(to: secondStore, status: .blocked) + + #expect(library.attentionAcrossWindows.map(\.session.id) == [newer.id, older.id]) + } + + @Test func attentionAcrossWindowsMatchesTheSingleWindowList() throws { + let library = WindowLibrary(directory: directory) + let store = try #require(library.activeStore) + _ = try addSession(to: store, status: .completed) + _ = try addSession(to: store, status: .blocked) + _ = try addSession(to: store, status: .active) + + #expect(library.attentionAcrossWindows.map(\.session.id) == store.attentionSessions.map(\.id)) + } + + @Test func attentionAcrossWindowsSkipsClosedWindows() throws { + let library = WindowLibrary(directory: directory) + let first = try #require(library.activeWindowID) + let firstStore = try #require(library.store(for: first)) + let second = library.newWindow(name: "second") + let secondStore = try #require(library.store(for: second.id)) + _ = try addSession(to: secondStore, status: .blocked) + let kept = try addSession(to: firstStore, status: .completed) + + library.closeWindow(second.id) + + #expect(library.attentionAcrossWindows.map(\.session.id) == [kept.id]) + } + + @Test func attentionAcrossWindowsInvalidatesWhenABackgroundWindowCloses() throws { + let library = WindowLibrary(directory: directory) + let first = try #require(library.activeWindowID) + let second = library.newWindow(name: "second") + let secondStore = try #require(library.store(for: second.id)) + _ = try addSession(to: secondStore, status: .blocked) + library.frontmostWindowID = first + let fired = Flag() + withObservationTracking { + _ = library.attentionAcrossWindows + } onChange: { + fired.set() + } + + library.closeWindow(second.id) + + #expect(fired.isSet) + #expect(library.windows.count == 2) + #expect(library.frontmostWindowID == first) + #expect(library.attentionAcrossWindows.isEmpty) + } + + @Test func attentionSubtitleNamesTheWindowOnlyWithSeveralOpen() throws { + let library = WindowLibrary(directory: directory) + let store = try #require(library.activeStore) + let session = try addSession(to: store, status: .blocked) + let single = try #require(library.attentionAcrossWindows.first) + #expect(library.attentionSubtitle(single) == "workspace 1 · \(session.subtitleDetail)") + + let second = library.newWindow(name: "second") + let entry = try #require(library.attentionAcrossWindows.first { $0.session.id == session.id }) + #expect(library.attentionSubtitle(entry) == "\(entry.window.name) · workspace 1 · \(session.subtitleDetail)") + + library.closeWindow(second.id) + #expect(library.attentionSubtitle(entry) == "workspace 1 · \(session.subtitleDetail)") + } + + private final class Flag: @unchecked Sendable { + private(set) var isSet = false + func set() { isSet = true } + } } diff --git a/agtermTests/AppActionsFocusTests.swift b/agtermTests/AppActionsFocusTests.swift index 6f58a1d34..e204c5a7c 100644 --- a/agtermTests/AppActionsFocusTests.swift +++ b/agtermTests/AppActionsFocusTests.swift @@ -91,4 +91,123 @@ final class AppActionsFocusTests: XCTestCase { from: Data(contentsOf: stateDir.appendingPathComponent("windows.json"))) XCTAssertEqual(saved.frontmost, windowB, "and persisted to the index") } + + private struct Pair { + let library: WindowLibrary + let actions: AppActions + let front: WindowInfo.ID + let back: WindowInfo.ID + let backStore: AppStore + let hostBack: NSWindow + } + + private func makePair() throws -> Pair { + let library = WindowLibrary(directory: stateDir) + let front = try XCTUnwrap(library.activeWindowID) + let back = library.newWindow(name: "back") + library.frontmostWindowID = front + _ = registerWindow(front) + let hostBack = registerWindow(back.id) + let backStore = try XCTUnwrap(library.store(for: back.id)) + return Pair(library: library, actions: AppActions(library: library), front: front, back: back.id, + backStore: backStore, hostBack: hostBack) + } + + private func addSession(to store: AppStore, _ indicator: AgentIndicator) throws -> Session { + let session = try XCTUnwrap(store.addSession(toWorkspace: store.workspaces[0].id, cwd: NSTemporaryDirectory(), + select: false)) + store.setAgentIndicator(indicator, forSession: session.id) + return session + } + + func testAttentionPickRaisesTheOwningWindowAndSelectsThere() throws { + let pair = try makePair() + let session = try addSession(to: pair.backStore, AgentIndicator(status: .blocked)) + var posted = 0 + let token = NotificationCenter.default.addObserver(forName: .agtermWindowFrontmostChanged, + object: nil, queue: .main) { _ in posted += 1 } + defer { NotificationCenter.default.removeObserver(token) } + + pair.actions.selectAttention(windowID: pair.back, sessionID: session.id) + + XCTAssertEqual(pair.library.frontmostWindowID, pair.back) + XCTAssertTrue(pair.hostBack.isVisible) + XCTAssertEqual(pair.backStore.activeSession?.id, session.id) + XCTAssertEqual(posted, 1) + } + + func testAttentionPickRevealsAHiddenScratchInTheOtherWindow() throws { + let pair = try makePair() + let session = try addSession(to: pair.backStore, AgentIndicator(status: .blocked, statusPane: .scratch)) + + pair.actions.selectAttention(windowID: pair.back, sessionID: session.id) + + XCTAssertTrue(session.scratchActive) + } + + func testAttentionPickHidesAShownScratchCoveringAMainTarget() throws { + let pair = try makePair() + let session = try addSession(to: pair.backStore, AgentIndicator(status: .blocked, statusPane: .left)) + pair.backStore.toggleScratch(session.id) + + pair.actions.selectAttention(windowID: pair.back, sessionID: session.id) + + XCTAssertFalse(session.scratchActive) + } + + func testAttentionPickKeepsTheCapturedPaneAfterAnAutoResetClears() throws { + let pair = try makePair() + let session = try addSession(to: pair.backStore, + AgentIndicator(status: .completed, autoReset: true, statusPane: .scratch)) + + pair.actions.selectAttention(windowID: pair.back, sessionID: session.id) + + XCTAssertEqual(session.agentIndicator.status, .idle) + XCTAssertTrue(session.scratchActive) + } + + func testAttentionPickLeavesAnActiveSessionsPanesAlone() throws { + let pair = try makePair() + let session = try addSession(to: pair.backStore, AgentIndicator(status: .active, statusPane: .left)) + pair.backStore.toggleScratch(session.id) + session.splitFocused = true + + pair.actions.selectAttention(windowID: pair.back, sessionID: session.id) + + XCTAssertEqual(pair.backStore.activeSession?.id, session.id) + XCTAssertTrue(session.scratchActive) + XCTAssertTrue(session.splitFocused) + } + + func testAttentionPickDropsWhenTheOwningWindowCannotBeRaised() throws { + let library = WindowLibrary(directory: stateDir) + let front = try XCTUnwrap(library.activeWindowID) + let back = library.newWindow(name: "back") + library.frontmostWindowID = front + _ = registerWindow(front) + let backStore = try XCTUnwrap(library.store(for: back.id)) + let before = backStore.activeSession?.id + let session = try addSession(to: backStore, AgentIndicator(status: .blocked)) + + AppActions(library: library).selectAttention(windowID: back.id, sessionID: session.id) + + XCTAssertEqual(library.frontmostWindowID, front) + XCTAssertEqual(backStore.activeSession?.id, before) + } + + func testAttentionPickRefusesATargetUnderTerminalZoom() throws { + let pair = try makePair() + let before = pair.backStore.activeSession?.id + let session = try addSession(to: pair.backStore, AgentIndicator(status: .blocked, statusPane: .scratch)) + let zoom = TerminalZoomController() + TerminalZoomRegistry.shared.register(pair.back, controller: zoom) + defer { TerminalZoomRegistry.shared.unregister(pair.back) } + zoom.set(.on, target: .session(session.id, .primary)) + + pair.actions.selectAttention(windowID: pair.back, sessionID: session.id) + + XCTAssertEqual(pair.library.frontmostWindowID, pair.front) + XCTAssertEqual(pair.backStore.activeSession?.id, before) + XCTAssertFalse(session.scratchActive) + } } diff --git a/agtermTests/AppActionsPaletteTests.swift b/agtermTests/AppActionsPaletteTests.swift index e0688e506..e97aac33e 100644 --- a/agtermTests/AppActionsPaletteTests.swift +++ b/agtermTests/AppActionsPaletteTests.swift @@ -145,4 +145,48 @@ final class AppActionsPaletteTests: XCTestCase { XCTAssertTrue(viaPalette.isDisjoint(with: paletteLess), "an action must have exactly one dispatch path") XCTAssertEqual(viaPalette.union(paletteLess), Set(BuiltinAction.allCases)) } + + func testAttentionRowsNameTheirWindowAndFollowItsCover() throws { + let front = try XCTUnwrap(library.activeWindowID) + let back = library.newWindow(name: "back") + library.frontmostWindowID = front + let backStore = try XCTUnwrap(library.store(for: back.id)) + let session = try XCTUnwrap(backStore.addSession(toWorkspace: backStore.workspaces[0].id, cwd: NSHomeDirectory(), + select: false)) + backStore.setAgentIndicator(AgentIndicator(status: .blocked), forSession: session.id) + + let row = try XCTUnwrap(actions.paletteAttention().first { $0.id == session.id.uuidString }) + XCTAssertEqual(row.subtitle?.hasPrefix("back · "), true) + XCTAssertEqual(row.status, .blocked) + XCTAssertTrue(row.isEnabled()) + + let zoom = TerminalZoomController() + TerminalZoomRegistry.shared.register(back.id, controller: zoom) + defer { TerminalZoomRegistry.shared.unregister(back.id) } + zoom.set(.on, target: .session(session.id, .primary)) + XCTAssertFalse(row.isEnabled(), "the row asks the owning window's cover, not the frontmost one's") + } + + func testAnAttentionRowGoesInertWhenItsWindowClosesUnderThePalette() throws { + let front = try XCTUnwrap(library.activeWindowID) + let back = library.newWindow(name: "back") + library.frontmostWindowID = front + let backStore = try XCTUnwrap(library.store(for: back.id)) + let session = try XCTUnwrap(backStore.addSession(toWorkspace: backStore.workspaces[0].id, cwd: NSHomeDirectory(), + select: false)) + backStore.setAgentIndicator(AgentIndicator(status: .blocked), forSession: session.id) + let row = try XCTUnwrap(actions.paletteAttention().first { $0.id == session.id.uuidString }) + let invalidated = expectation(description: "the row's enablement is invalidated by the close") + withObservationTracking { + XCTAssertTrue(row.isEnabled()) + } onChange: { + invalidated.fulfill() + } + + library.closeWindow(back.id) + + wait(for: [invalidated], timeout: 1) + XCTAssertFalse(row.isEnabled()) + XCTAssertFalse(row.runIfEnabled()) + } } diff --git a/plugins/agterm/skills/agterm/reference.md b/plugins/agterm/skills/agterm/reference.md index 9b716feb4..d193c23a7 100644 --- a/plugins/agterm/skills/agterm/reference.md +++ b/plugins/agterm/skills/agterm/reference.md @@ -1189,8 +1189,9 @@ For agentic attention (waiting on input, or a finished result), prefer `session and OSC 9/777. The two overlap, either can raise an "I need you" signal, but a notification is a one-shot banner and badge with no lasting state, while `session status` is a typed, persistent state (`active`/`blocked`/`completed`) that stays on the row until acted on, is more precise, and drives the -attention list, the title-bar bell, and attention navigation (`session go --to next-attention`). Keep -`notify` for a one-off nudge that needs no follow-up. +attention list, the title-bar bell, and attention navigation (`session go --to next-attention`). The list +and the bell span every open window; attention navigation steps within the window. Keep `notify` for a +one-off nudge that needs no follow-up. ## font diff --git a/site/docs.html b/site/docs.html index 5ee414c19..7dbcaf0d5 100644 --- a/site/docs.html +++ b/site/docs.html @@ -2980,13 +2980,15 @@

When the sidebar is hidden the glyphs go with it, so an optional title-bar bell (Settings ▸ Notifications ▸ Show attention indicator, off by - default) reflects the window: dimmed when nothing needs you, plain when a session is active or completed, filled - amber when any is blocked. Clicking it opens a popover of this window's non-idle - sessions, sorted blocked → active → completed, that you hover to highlight and click to jump to the session. + default) reflects every open window: dimmed when nothing needs you, plain when a session is active or completed, + filled amber when any is blocked. Clicking it opens a popover of the non-idle + sessions across all open windows, sorted blocked → active → completed and newest first within each, that you + hover to highlight and click to jump to the session. With more than one window open each row names its window, + and picking a session in another window brings that window forward first. A blocked or completed row also reveals the pane that set its status; an active row keeps the existing pane selection. ⌃⇧I, Navigate ▸ Go to Attention…, or the action palette's "Show Attention" opens the same attention list as a searchable - palette. Right-clicking agterm's Dock icon exposes the last-active window's same ordered list under + palette. Right-clicking agterm's Dock icon exposes the last-active window's own ordered list under Sessions Needing Attention, alongside its recent sessions. Over the control channel, agtermctl tree --json reports each session's status and