From 2973e3fb1262e9b2487c36a910e550cfdff38021 Mon Sep 17 00:00:00 2001 From: duroy Date: Sat, 6 Jun 2026 16:29:59 +0800 Subject: [PATCH 01/10] test: cover notification turn-start anchors --- cmuxTests/SessionPersistenceTests.swift | 5 ++- cmuxTests/TerminalAndGhosttyTests.swift | 26 ++++++++++++++ ...erminalNotificationSocketActionTests.swift | 34 +++++++++++++++++-- cmuxTests/WorkspacePromptSubmitTests.swift | 28 +++++++++++++++ 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index b73c09fcd79f..07520de9d2e1 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -98,13 +98,15 @@ final class SessionPersistenceTests: XCTestCase { body: "Tests passed", createdAt: Date(timeIntervalSince1970: 1_700_000_000), isRead: false, - paneFlash: true + paneFlash: true, + openAnchor: TerminalNotificationOpenAnchor(scrollbarOffset: 64) ) store.replaceNotificationsForTesting([notification]) let snapshot = workspace.sessionSnapshot(includeScrollback: false) let panelSnapshot = try XCTUnwrap(snapshot.panels.first { $0.id == panelId }) XCTAssertEqual(panelSnapshot.notifications?.first?.body, "Tests passed") + XCTAssertEqual(panelSnapshot.notifications?.first?.openAnchor?.scrollbarOffset, 64) store.replaceNotificationsForTesting([]) let restored = Workspace() @@ -116,6 +118,7 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(restoredNotification.panelId, restoredPanelId) XCTAssertEqual(restoredNotification.title, "Agent finished") XCTAssertEqual(restoredNotification.body, "Tests passed") + XCTAssertEqual(restoredNotification.openAnchor?.scrollbarOffset, 64) XCTAssertFalse(restoredNotification.isRead) XCTAssertTrue(store.hasUnreadNotification(forTabId: restored.id, surfaceId: restoredPanelId)) let restoredSurfaceId = try XCTUnwrap(restored.surfaceIdFromPanelId(restoredPanelId)) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 835efa4e7eee..dae3c916cc1c 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -3961,6 +3961,32 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) } + func testNotificationOpenAnchorConvertsAbsoluteScrollOffsetToGhosttyRow() { + let anchor = TerminalNotificationOpenAnchor(scrollbarOffset: 80) + let scrollbar = makeScrollbar(total: 150, offset: 130, len: 20) + + XCTAssertEqual( + GhosttySurfaceScrollView.notificationOpenScrollRow( + for: anchor, + scrollbar: scrollbar + ), + 50 + ) + } + + func testNotificationOpenAnchorClampsPastBottomToBottomRow() { + let anchor = TerminalNotificationOpenAnchor(scrollbarOffset: 140) + let scrollbar = makeScrollbar(total: 150, offset: 130, len: 20) + + XCTAssertEqual( + GhosttySurfaceScrollView.notificationOpenScrollRow( + for: anchor, + scrollbar: scrollbar + ), + 0 + ) + } + override func tearDown() { GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil for surface in surfacesToRelease.reversed() { diff --git a/cmuxTests/TerminalNotificationSocketActionTests.swift b/cmuxTests/TerminalNotificationSocketActionTests.swift index 6ef40948084e..3f4d1f697837 100644 --- a/cmuxTests/TerminalNotificationSocketActionTests.swift +++ b/cmuxTests/TerminalNotificationSocketActionTests.swift @@ -163,6 +163,34 @@ final class TerminalNotificationSocketActionTests: XCTestCase { XCTAssertEqual(fixture.notification(notification.id)?.isRead, true) } + func testNotificationOpenPayloadIncludesTurnStartAnchor() async throws { + let fixture = try makeSocketFixture(name: "notif-open-anchor", includeWindow: true) + defer { fixture.cleanup() } + + let targetWorkspace = fixture.manager.addWorkspace(title: "Open Anchor Target", select: false) + let targetSurfaceId = try XCTUnwrap(targetWorkspace.focusedPanelId) + let anchor = TerminalNotificationOpenAnchor(scrollbarOffset: 123) + let notification = makeNotification( + tabId: targetWorkspace.id, + surfaceId: targetSurfaceId, + title: "Open Anchor", + openAnchor: anchor + ) + fixture.store.replaceNotificationsForTesting([notification]) + fixture.manager.selectTab(fixture.workspace) + + let response = try await sendV2RequestAsync( + method: "notification.open", + params: ["id": notification.id.uuidString], + to: fixture.socketPath + ) + + XCTAssertEqual(response["ok"] as? Bool, true, "\(response)") + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let openAnchor = try XCTUnwrap(result["open_anchor"] as? [String: Any]) + XCTAssertEqual(openAnchor["scrollbar_offset"] as? UInt64, anchor.scrollbarOffset) + } + func testNotificationJumpToUnreadOpensLatestUnreadAndNoOpsWhenNoneRemain() async throws { let fixture = try makeSocketFixture(name: "notif-jump", includeWindow: true) defer { fixture.cleanup() } @@ -341,7 +369,8 @@ final class TerminalNotificationSocketActionTests: XCTestCase { tabId: UUID, surfaceId: UUID?, title: String, - isRead: Bool = false + isRead: Bool = false, + openAnchor: TerminalNotificationOpenAnchor? = nil ) -> TerminalNotification { TerminalNotification( id: UUID(), @@ -351,7 +380,8 @@ final class TerminalNotificationSocketActionTests: XCTestCase { subtitle: "socket-test", body: "body", createdAt: Date(timeIntervalSince1970: 1_778_888_888), - isRead: isRead + isRead: isRead, + openAnchor: openAnchor ) } diff --git a/cmuxTests/WorkspacePromptSubmitTests.swift b/cmuxTests/WorkspacePromptSubmitTests.swift index 17e603438bfc..9c2fd39f065a 100644 --- a/cmuxTests/WorkspacePromptSubmitTests.swift +++ b/cmuxTests/WorkspacePromptSubmitTests.swift @@ -176,6 +176,34 @@ final class WorkspacePromptSubmitTests: XCTestCase { XCTAssertEqual(CmuxEventBus.shared.latestSequence, sequenceBeforeSubmit) } + func testPromptSubmitOpenAnchorIsAppliedToNextSurfaceNotification() throws { + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _, _ in } + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + } + + let workspaceId = UUID() + let surfaceId = UUID() + let anchor = TerminalNotificationOpenAnchor(scrollbarOffset: 42) + store.recordPromptSubmitOpenAnchor(anchor, forTabId: workspaceId, surfaceId: surfaceId) + + store.addNotification( + tabId: workspaceId, + surfaceId: surfaceId, + title: "Agent finished", + subtitle: "codex", + body: "Done" + ) + + let notification = try XCTUnwrap(store.notifications.first) + XCTAssertEqual(notification.openAnchor, anchor) + } + func testFeedPromptSubmitEventExtractsToolInputMessage() throws { let manager = TabManager() let first = manager.tabs[0] From 4787b51f3e16c0a8a414247ff8b2f5b762792401 Mon Sep 17 00:00:00 2001 From: duroy Date: Sat, 6 Jun 2026 16:35:44 +0800 Subject: [PATCH 02/10] fix: reopen notifications at prompt start --- Sources/AppDelegate.swift | 88 +++++++++++++++++++++++-- Sources/GhosttyTerminalView.swift | 39 +++++++++++ Sources/Panels/TerminalPanel.swift | 17 +++++ Sources/SessionPersistence.swift | 11 +++- Sources/TerminalController.swift | 7 ++ Sources/TerminalNotificationStore.swift | 60 +++++++++++++++-- Sources/WorkspacePromptSubmit.swift | 47 ++++++++++++- 7 files changed, 252 insertions(+), 17 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0e1a72ecf129..939294a2cf9c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15981,8 +15981,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } return openNotification( tabId: notification.tabId, - surfaceId: notification.surfaceId, - notificationId: notification.id + surfaceId: notification.panelId ?? notification.surfaceId, + notificationId: notification.id, + openAnchor: notification.openAnchor ) } @@ -16016,7 +16017,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @discardableResult - func openNotification(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { + func openNotification( + tabId: UUID, + surfaceId: UUID?, + notificationId: UUID?, + openAnchor: TerminalNotificationOpenAnchor? = nil + ) -> Bool { + let resolvedOpenAnchor = openAnchor ?? notificationId.flatMap { id in + notificationStore?.notifications.first(where: { $0.id == id })?.openAnchor + } #if DEBUG let isJumpUnreadUITest = ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" if isJumpUnreadUITest { @@ -16041,7 +16050,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "0", "jumpUnreadOpenUsedFallback": "1"]) } #endif - let ok = openNotificationFallback(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + let ok = openNotificationFallback( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + openAnchor: resolvedOpenAnchor + ) #if DEBUG if isJumpUnreadUITest { writeJumpUnreadTestData(["jumpUnreadOpenResult": ok ? "1" : "0"]) @@ -16054,10 +16068,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "1", "jumpUnreadOpenUsedFallback": "0"]) } #endif - return openNotificationInContext(context, tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + return openNotificationInContext( + context, + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + openAnchor: resolvedOpenAnchor + ) } - private func openNotificationInContext(_ context: MainWindowContext, tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { + private func openNotificationInContext( + _ context: MainWindowContext, + tabId: UUID, + surfaceId: UUID?, + notificationId: UUID?, + openAnchor: TerminalNotificationOpenAnchor? = nil + ) -> Bool { let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) guard let window else { @@ -16088,6 +16114,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return false } + _ = scrollToNotificationOpenAnchor(openAnchor, tabId: tabId, surfaceId: surfaceId, tabManager: context.tabManager) #if DEBUG // UI test support: Jump-to-unread asserts that the correct workspace/panel is focused. @@ -16117,7 +16144,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - private func openNotificationFallback(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { + private func openNotificationFallback( + tabId: UUID, + surfaceId: UUID?, + notificationId: UUID?, + openAnchor: TerminalNotificationOpenAnchor? = nil + ) -> Bool { // If the owning window context hasn't been registered yet, fall back to the "active" window. guard let tabManager else { #if DEBUG @@ -16157,6 +16189,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return false } + _ = scrollToNotificationOpenAnchor(openAnchor, tabId: tabId, surfaceId: surfaceId, tabManager: tabManager) #if DEBUG recordJumpUnreadFocusFromModelIfNeeded( @@ -16177,6 +16210,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + /// Scrolls a focused notification target back to its captured prompt-submit anchor. + /// + /// - Parameters: + /// - openAnchor: Optional scrollback anchor stored on the notification. + /// - tabId: Workspace identifier for the notification target. + /// - surfaceId: Optional panel or surface identifier focused for the notification. + /// - tabManager: Tab manager that owns the focused workspace. + /// - Returns: `true` if a terminal panel accepted the scroll request. + @discardableResult + private func scrollToNotificationOpenAnchor( + _ openAnchor: TerminalNotificationOpenAnchor?, + tabId: UUID, + surfaceId: UUID?, + tabManager: TabManager + ) -> Bool { + guard let openAnchor, + let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + return false + } + + let panelId: UUID? + if let surfaceId, workspace.panels[surfaceId] != nil { + panelId = surfaceId + } else if let surfaceId { + panelId = workspace.panelIdFromSurfaceId(TabID(uuid: surfaceId)) + } else { + panelId = workspace.focusedPanelId + } + guard let panelId, + let terminalPanel = workspace.terminalPanel(for: panelId) else { + return false + } + let didScroll = terminalPanel.scrollToNotificationOpenAnchor(openAnchor) + if !didScroll { + DispatchQueue.main.async { [weak terminalPanel] in + _ = terminalPanel?.scrollToNotificationOpenAnchor(openAnchor) + } + } + return didScroll + } + #if DEBUG private func recordJumpUnreadFocusFromModelIfNeeded( tabManager: TabManager, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8e02f7d4b687..a37fa72ef0ab 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -15241,6 +15241,45 @@ final class GhosttySurfaceScrollView: NSView { layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) } + /// Returns the current absolute scroll offset for later notification reopening. + /// + /// - Returns: A notification open anchor, or `nil` until Ghostty reports scrollbar state. + func notificationOpenAnchor() -> TerminalNotificationOpenAnchor? { + guard let scrollbar = surfaceView.scrollbar else { return nil } + return TerminalNotificationOpenAnchor(scrollbarOffset: scrollbar.offset) + } + + /// Converts a saved absolute scroll offset into Ghostty's bottom-relative scroll row. + /// + /// - Parameters: + /// - anchor: Prompt-submit anchor containing the absolute top-row offset. + /// - scrollbar: Current Ghostty scrollbar state. + /// - Returns: Bottom-relative row count suitable for `scroll_to_row:`. + static func notificationOpenScrollRow( + for anchor: TerminalNotificationOpenAnchor, + scrollbar: GhosttyScrollbar + ) -> Int { + guard scrollbar.total > scrollbar.len else { return 0 } + let bottomTopOffset = scrollbar.total - scrollbar.len + guard anchor.scrollbarOffset < bottomTopOffset else { return 0 } + let row = bottomTopOffset - anchor.scrollbarOffset + return row > UInt64(Int.max) ? Int.max : Int(row) + } + + /// Scrolls Ghostty back to the prompt-submit anchor for a reopened notification. + /// + /// - Parameter anchor: The absolute scrollback anchor captured when the prompt was submitted. + /// - Returns: `true` if the scroll action was sent to Ghostty. + @discardableResult + func scrollToNotificationOpenAnchor(_ anchor: TerminalNotificationOpenAnchor) -> Bool { + guard let scrollbar = surfaceView.scrollbar else { return false } + let row = Self.notificationOpenScrollRow(for: anchor, scrollbar: scrollbar) + userScrolledAwayFromBottom = row > 0 + allowExplicitScrollbarSync = true + lastSentRow = row + return surfaceView.performBindingAction("scroll_to_row:\(row)") + } + private func synchronizeScrollView() { var didChangeGeometry = false let targetDocumentHeight = documentHeight() diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index eb2b8848c1c6..a2e0255e11fd 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -700,6 +700,23 @@ final class TerminalPanel: Panel, ObservableObject { return surface.performBindingAction(action) } + /// Returns the current scroll position to use when reopening future notifications. + /// + /// - Returns: A notification open anchor for this terminal, if Ghostty has reported scroll state. + func notificationOpenAnchor() -> TerminalNotificationOpenAnchor? { + hostedView.notificationOpenAnchor() + } + + /// Scrolls the terminal back to a prompt-submit anchor after notification focus. + /// + /// - Parameter anchor: The absolute scrollback anchor captured when the prompt was submitted. + /// - Returns: `true` if the scroll action was sent to Ghostty. + @discardableResult + func scrollToNotificationOpenAnchor(_ anchor: TerminalNotificationOpenAnchor) -> Bool { + guard !isAgentHibernated else { return false } + return hostedView.scrollToNotificationOpenAnchor(anchor) + } + private func resumeForExplicitInputIfNeeded() { guard isAgentHibernated else { return } _ = requestAgentHibernationResume(focus: false) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index deba59d19ebb..b8e3224a5135 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1640,6 +1640,7 @@ struct SessionNotificationSnapshot: Codable, Sendable { var isRead: Bool var paneFlash: Bool? var clickAction: TerminalNotificationClickAction? + var openAnchor: TerminalNotificationOpenAnchor? init( id: UUID, @@ -1649,7 +1650,8 @@ struct SessionNotificationSnapshot: Codable, Sendable { createdAt: TimeInterval, isRead: Bool, paneFlash: Bool? = nil, - clickAction: TerminalNotificationClickAction? = nil + clickAction: TerminalNotificationClickAction? = nil, + openAnchor: TerminalNotificationOpenAnchor? = nil ) { self.id = id self.title = title @@ -1659,6 +1661,7 @@ struct SessionNotificationSnapshot: Codable, Sendable { self.isRead = isRead self.paneFlash = paneFlash self.clickAction = clickAction + self.openAnchor = openAnchor } init(notification: TerminalNotification) { @@ -1670,7 +1673,8 @@ struct SessionNotificationSnapshot: Codable, Sendable { createdAt: notification.createdAt.timeIntervalSince1970, isRead: notification.isRead, paneFlash: notification.paneFlash, - clickAction: notification.clickAction + clickAction: notification.clickAction, + openAnchor: notification.openAnchor ) } @@ -1686,7 +1690,8 @@ struct SessionNotificationSnapshot: Codable, Sendable { createdAt: Date(timeIntervalSince1970: createdAt), isRead: isRead, paneFlash: paneFlash ?? true, - clickAction: clickAction + clickAction: clickAction, + openAnchor: openAnchor ) } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index a7ad06cf58f7..757ab769593d 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4786,6 +4786,7 @@ class TerminalController { } } let message = messageKeys.lazy.compactMap { self.v2RawString(params, $0) }.first + let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") guard let tabManager = v2ResolveWorkspaceOwner(workspaceId) ?? v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } @@ -4799,6 +4800,7 @@ class TerminalController { outcome = tabManager.handlePromptSubmit( workspaceId: workspaceId, message: message, + surfaceId: surfaceId, iMessageModeEnabled: iMessageModeEnabled ) preview = tabManager.tabs.first(where: { $0.id == workspaceId })?.latestSubmittedMessage @@ -10172,6 +10174,11 @@ class TerminalController { if let opened { payload["opened"] = opened } + if let openAnchor = notification.openAnchor { + payload["open_anchor"] = [ + "scrollbar_offset": openAnchor.scrollbarOffset + ] + } return payload } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index e42d2861e15c..2f3b67af252b 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -782,6 +782,7 @@ struct TerminalNotification: Identifiable, Hashable { var isRead: Bool var paneFlash: Bool = true var clickAction: TerminalNotificationClickAction? + var openAnchor: TerminalNotificationOpenAnchor? init( id: UUID, @@ -794,7 +795,8 @@ struct TerminalNotification: Identifiable, Hashable { createdAt: Date, isRead: Bool, paneFlash: Bool = true, - clickAction: TerminalNotificationClickAction? = nil + clickAction: TerminalNotificationClickAction? = nil, + openAnchor: TerminalNotificationOpenAnchor? = nil ) { self.id = id self.tabId = tabId @@ -807,6 +809,7 @@ struct TerminalNotification: Identifiable, Hashable { self.isRead = isRead self.paneFlash = paneFlash self.clickAction = clickAction + self.openAnchor = openAnchor } func matches(tabId targetTabId: UUID, surfaceId targetSurfaceId: UUID?) -> Bool { @@ -818,6 +821,17 @@ struct TerminalNotification: Identifiable, Hashable { } } +struct TerminalNotificationOpenAnchor: Codable, Hashable, Sendable { + let scrollbarOffset: UInt64 + + /// Creates a notification reopen anchor from Ghostty's absolute scrollbar offset. + /// + /// - Parameter scrollbarOffset: Absolute top-row offset reported by Ghostty at prompt submit time. + init(scrollbarOffset: UInt64) { + self.scrollbarOffset = scrollbarOffset + } +} + @MainActor final class TerminalNotificationStore: ObservableObject { private struct TabSurfaceKey: Hashable { @@ -909,6 +923,7 @@ final class TerminalNotificationStore: ObservableObject { private static let notificationHookFailureThrottle: TimeInterval = 300 private var lastNotificationDateByCooldownKey: [String: Date] = [:] private var lastNotificationHookFailureDateByKey: [NotificationHookFailureThrottleKey: Date] = [:] + private var promptSubmitOpenAnchors: [TabSurfaceKey: TerminalNotificationOpenAnchor] = [:] private var indexes = NotificationIndexes() private init() { @@ -1223,6 +1238,35 @@ final class TerminalNotificationStore: ObservableObject { notifications.filter { $0.matches(tabId: tabId, surfaceId: surfaceId) } } + /// Records the terminal scroll position that a future notification should reopen to. + /// + /// - Parameters: + /// - anchor: Absolute terminal scrollback anchor captured at prompt submit time. + /// - tabId: Workspace identifier that owns the terminal surface. + /// - surfaceId: Terminal surface or panel identifier within the workspace. + func recordPromptSubmitOpenAnchor( + _ anchor: TerminalNotificationOpenAnchor, + forTabId tabId: UUID, + surfaceId: UUID? + ) { + promptSubmitOpenAnchors[TabSurfaceKey(tabId: tabId, surfaceId: surfaceId)] = anchor + } + + /// Returns the prompt-submit anchor for a workspace/surface notification target. + /// + /// - Parameters: + /// - tabId: Workspace identifier that owns the notification. + /// - surfaceId: Terminal surface or panel identifier for the notification. + /// - Returns: The most recent prompt-submit anchor for the target, if one was recorded. + func promptSubmitOpenAnchor(forTabId tabId: UUID, surfaceId: UUID?) -> TerminalNotificationOpenAnchor? { + let target = TabSurfaceKey(tabId: tabId, surfaceId: surfaceId) + if let anchor = promptSubmitOpenAnchors[target] { + return anchor + } + guard surfaceId != nil else { return nil } + return promptSubmitOpenAnchors[TabSurfaceKey(tabId: tabId, surfaceId: nil)] + } + func clearLatestNotification(forTabId tabId: UUID) { guard let latestNotification = indexes.latestByTabId[tabId] else { return } remove(id: latestNotification.id) @@ -1455,6 +1499,10 @@ final class TerminalNotificationStore: ObservableObject { tabId: request.tabId, surfaceId: request.surfaceId ) + let openAnchor = promptSubmitOpenAnchor( + forTabId: request.tabId, + surfaceId: request.panelId ?? request.surfaceId + ) let notification = TerminalNotification( id: UUID(), tabId: request.tabId, @@ -1466,7 +1514,8 @@ final class TerminalNotificationStore: ObservableObject { createdAt: now, isRead: !effects.markUnread, paneFlash: effects.paneFlash, - clickAction: clickAction + clickAction: clickAction, + openAnchor: openAnchor ) if effects.record { @@ -1852,7 +1901,8 @@ final class TerminalNotificationStore: ObservableObject { createdAt: notification.createdAt, isRead: notification.isRead, paneFlash: notification.paneFlash, - clickAction: notification.clickAction + clickAction: notification.clickAction, + openAnchor: notification.openAnchor ) } @@ -1932,7 +1982,8 @@ final class TerminalNotificationStore: ObservableObject { createdAt: notification.createdAt, isRead: notification.isRead, paneFlash: notification.paneFlash, - clickAction: notification.clickAction + clickAction: notification.clickAction, + openAnchor: notification.openAnchor ) } if didMoveNotification { @@ -2360,6 +2411,7 @@ final class TerminalNotificationStore: ObservableObject { func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) { TerminalMutationBus.shared.discardPendingNotifications() self.notifications = notifications + promptSubmitOpenAnchors.removeAll() clearWorkspaceManualUnread() clearPanelDerivedWorkspaceUnread() clearWorkspaceRestoredUnread() diff --git a/Sources/WorkspacePromptSubmit.swift b/Sources/WorkspacePromptSubmit.swift index daa2e68f9399..b2727cca6034 100644 --- a/Sources/WorkspacePromptSubmit.swift +++ b/Sources/WorkspacePromptSubmit.swift @@ -1,4 +1,5 @@ import CMUXWorkstream +import Bonsplit import Foundation enum IMessageModeSettings { @@ -146,6 +147,7 @@ extension TabManager { func handlePromptSubmit( workspaceId: UUID, message: String?, + surfaceId: UUID? = nil, iMessageModeEnabled: Bool = IMessageModeSettings.isEnabled() ) -> (messageRecorded: Bool, reordered: Bool, index: Int)? { handleConversationMessage( @@ -153,7 +155,8 @@ extension TabManager { message: message, iMessageModeEnabled: iMessageModeEnabled, kind: .promptSubmission, - reorderWithoutMessage: true + reorderWithoutMessage: true, + surfaceId: surfaceId ) } @@ -168,7 +171,8 @@ extension TabManager { message: message, iMessageModeEnabled: iMessageModeEnabled, kind: .assistantFinal, - reorderWithoutMessage: false + reorderWithoutMessage: false, + surfaceId: nil ) } @@ -177,13 +181,17 @@ extension TabManager { message: String?, iMessageModeEnabled: Bool, kind: ConversationMessageKind, - reorderWithoutMessage: Bool + reorderWithoutMessage: Bool, + surfaceId: UUID? ) -> (messageRecorded: Bool, reordered: Bool, index: Int)? { guard let originalIndex = tabs.firstIndex(where: { $0.id == workspaceId }) else { return nil } let workspace = tabs[originalIndex] + if case .promptSubmission = kind { + recordPromptSubmitOpenAnchors(in: workspace, surfaceId: surfaceId) + } let hasMessage = Workspace.conversationMessagePreview(from: message) != nil let messageRecorded: Bool switch kind { @@ -212,6 +220,39 @@ extension TabManager { let newIndex = tabs.firstIndex(where: { $0.id == workspaceId }) ?? originalIndex return (messageRecorded, newIndex != originalIndex, newIndex) } + + /// Records prompt-submit notification anchors for the affected terminal panel(s). + /// + /// - Parameters: + /// - workspace: Workspace that received a prompt submit event. + /// - surfaceId: Optional panel or surface identifier supplied by the caller. + private func recordPromptSubmitOpenAnchors(in workspace: Workspace, surfaceId: UUID?) { + let store = AppDelegate.shared?.notificationStore ?? TerminalNotificationStore.shared + let panelIds: [UUID] + if let surfaceId { + if workspace.panels[surfaceId] != nil { + panelIds = [surfaceId] + } else if let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: surfaceId)) { + panelIds = [panelId] + } else { + panelIds = [] + } + } else { + panelIds = Array(workspace.panels.keys) + } + + for panelId in panelIds { + guard let terminalPanel = workspace.terminalPanel(for: panelId), + let anchor = terminalPanel.notificationOpenAnchor() else { continue } + store.recordPromptSubmitOpenAnchor(anchor, forTabId: workspace.id, surfaceId: panelId) + if surfaceId != nil || panelId == workspace.focusedPanelId { + store.recordPromptSubmitOpenAnchor(anchor, forTabId: workspace.id, surfaceId: nil) + } + if let bonsplitTabId = workspace.surfaceIdFromPanelId(panelId)?.uuid { + store.recordPromptSubmitOpenAnchor(anchor, forTabId: workspace.id, surfaceId: bonsplitTabId) + } + } + } } extension Workspace { From 2de6880a34c3060a9b195dc1c92da03a5e18ebdf Mon Sep 17 00:00:00 2001 From: duroy Date: Sat, 6 Jun 2026 17:28:47 +0800 Subject: [PATCH 03/10] fix: address notification anchor review feedback --- Sources/AppDelegate.swift | 8 +- Sources/GhosttyTerminalView.swift | 35 +++++++- Sources/TerminalController.swift | 2 +- cmuxTests/TerminalAndGhosttyTests.swift | 32 ++++++++ ...erminalNotificationSocketActionTests.swift | 80 +++++++++++++++++++ 5 files changed, 147 insertions(+), 10 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 939294a2cf9c..aa88cea9d8e3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -16242,13 +16242,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let terminalPanel = workspace.terminalPanel(for: panelId) else { return false } - let didScroll = terminalPanel.scrollToNotificationOpenAnchor(openAnchor) - if !didScroll { - DispatchQueue.main.async { [weak terminalPanel] in - _ = terminalPanel?.scrollToNotificationOpenAnchor(openAnchor) - } - } - return didScroll + return terminalPanel.scrollToNotificationOpenAnchor(openAnchor) } #if DEBUG diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a37fa72ef0ab..46e6ba926dfe 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -12304,6 +12304,7 @@ final class GhosttySurfaceScrollView: NSView { private var userScrolledAwayFromBottom = false private var pendingExplicitWheelScroll = false private var allowExplicitScrollbarSync = false + private var pendingNotificationOpenAnchor: TerminalNotificationOpenAnchor? /// Threshold in points from bottom to consider "at bottom" (allows for minor float drift) private static let scrollToBottomThreshold: CGFloat = 5.0 private var isActive = true @@ -15269,10 +15270,28 @@ final class GhosttySurfaceScrollView: NSView { /// Scrolls Ghostty back to the prompt-submit anchor for a reopened notification. /// /// - Parameter anchor: The absolute scrollback anchor captured when the prompt was submitted. - /// - Returns: `true` if the scroll action was sent to Ghostty. + /// - Returns: `true` if the scroll action was sent to Ghostty or queued for the next scrollbar update. @discardableResult func scrollToNotificationOpenAnchor(_ anchor: TerminalNotificationOpenAnchor) -> Bool { - guard let scrollbar = surfaceView.scrollbar else { return false } + guard let scrollbar = surfaceView.scrollbar else { + pendingNotificationOpenAnchor = anchor + return true + } + pendingNotificationOpenAnchor = nil + return applyNotificationOpenAnchor(anchor, scrollbar: scrollbar) + } + + /// Applies a notification open anchor using a known Ghostty scrollbar state. + /// + /// - Parameters: + /// - anchor: The absolute scrollback anchor captured when the prompt was submitted. + /// - scrollbar: Current Ghostty scrollbar state used for row conversion. + /// - Returns: `true` if Ghostty accepted the scroll binding action. + @discardableResult + private func applyNotificationOpenAnchor( + _ anchor: TerminalNotificationOpenAnchor, + scrollbar: GhosttyScrollbar + ) -> Bool { let row = Self.notificationOpenScrollRow(for: anchor, scrollbar: scrollbar) userScrolledAwayFromBottom = row > 0 allowExplicitScrollbarSync = true @@ -15280,6 +15299,16 @@ final class GhosttySurfaceScrollView: NSView { return surfaceView.performBindingAction("scroll_to_row:\(row)") } + /// Applies a queued notification open anchor once Ghostty reports scrollbar readiness. + /// + /// - Parameter scrollbar: Current Ghostty scrollbar state from the readiness notification. + private func applyPendingNotificationOpenAnchorIfPossible(scrollbar: GhosttyScrollbar) { + guard let pendingNotificationOpenAnchor else { return } + if applyNotificationOpenAnchor(pendingNotificationOpenAnchor, scrollbar: scrollbar) { + self.pendingNotificationOpenAnchor = nil + } + } + private func synchronizeScrollView() { var didChangeGeometry = false let targetDocumentHeight = documentHeight() @@ -15367,9 +15396,11 @@ final class GhosttySurfaceScrollView: NSView { let isVisible = shouldShowTerminalScrollBar() if wasVisible != isVisible { _ = synchronizeGeometryAndContent() + applyPendingNotificationOpenAnchorIfPossible(scrollbar: scrollbar) return } synchronizeScrollView() + applyPendingNotificationOpenAnchorIfPossible(scrollbar: scrollbar) } @discardableResult diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 757ab769593d..ece68763115e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4786,7 +4786,7 @@ class TerminalController { } } let message = messageKeys.lazy.compactMap { self.v2RawString(params, $0) }.first - let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") + let surfaceId = v2UUID(params, "surface_id") guard let tabManager = v2ResolveWorkspaceOwner(workspaceId) ?? v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index dae3c916cc1c..9913fb0e7331 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -3947,6 +3947,19 @@ final class GhosttySurfaceOverlayTests: XCTestCase { } } + private final class BindingActionProbeSurfaceView: GhosttyNSView { + private(set) var performedBindingActions: [String] = [] + + /// Records Ghostty binding actions without sending them to a real surface. + /// + /// - Parameter action: Ghostty binding action requested by the scroll view. + /// - Returns: `true` so callers treat the action as accepted. + override func performBindingAction(_ action: String) -> Bool { + performedBindingActions.append(action) + return true + } + } + private final class KeyStatusTestWindow: NSWindow { override var isKeyWindow: Bool { true } } @@ -3987,6 +4000,25 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) } + /// Verifies notification reopen scrolling is queued until Ghostty publishes scrollbar state. + func testNotificationOpenAnchorWaitsForScrollbarBeforeScrolling() { + let surfaceView = BindingActionProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + let anchor = TerminalNotificationOpenAnchor(scrollbarOffset: 80) + + XCTAssertTrue(hostedView.scrollToNotificationOpenAnchor(anchor)) + XCTAssertTrue(surfaceView.performedBindingActions.isEmpty) + + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 150, offset: 130, len: 20)] + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + + XCTAssertEqual(surfaceView.performedBindingActions, ["scroll_to_row:50"]) + } + override func tearDown() { GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil for surface in surfacesToRelease.reversed() { diff --git a/cmuxTests/TerminalNotificationSocketActionTests.swift b/cmuxTests/TerminalNotificationSocketActionTests.swift index 3f4d1f697837..2a60235658c1 100644 --- a/cmuxTests/TerminalNotificationSocketActionTests.swift +++ b/cmuxTests/TerminalNotificationSocketActionTests.swift @@ -191,6 +191,36 @@ final class TerminalNotificationSocketActionTests: XCTestCase { XCTAssertEqual(openAnchor["scrollbar_offset"] as? UInt64, anchor.scrollbarOffset) } + /// Verifies prompt-submit requests without a surface id record an anchor for the focused terminal. + func testPromptSubmitWithoutSurfaceIdRecordsAnchorForFocusedTerminalNotification() async throws { + let fixture = try makeSocketFixture(name: "prompt-submit-anchor") + defer { fixture.cleanup() } + + let scrollbar = makeScrollbar(total: 240, offset: 64, len: 20) + try setScrollbar(scrollbar, forPanelId: fixture.surfaceId, in: fixture.workspace) + + let response = try await sendV2RequestAsync( + method: "workspace.prompt_submit", + params: [ + "workspace_id": fixture.workspace.id.uuidString, + "tab_id": fixture.workspace.id.uuidString, + "message": "run the task" + ], + to: fixture.socketPath + ) + + XCTAssertEqual(response["ok"] as? Bool, true, "\(response)") + fixture.store.addNotification( + tabId: fixture.workspace.id, + surfaceId: fixture.surfaceId, + title: "Agent finished", + subtitle: "codex", + body: "Done" + ) + let notification = try XCTUnwrap(fixture.store.notifications.first) + XCTAssertEqual(notification.openAnchor?.scrollbarOffset, scrollbar.offset) + } + func testNotificationJumpToUnreadOpensLatestUnreadAndNoOpsWhenNoneRemain() async throws { let fixture = try makeSocketFixture(name: "notif-jump", includeWindow: true) defer { fixture.cleanup() } @@ -385,6 +415,56 @@ final class TerminalNotificationSocketActionTests: XCTestCase { ) } + /// Builds a Ghostty scrollbar value for notification anchor tests. + /// + /// - Parameters: + /// - total: Total scrollback rows reported by Ghostty. + /// - offset: Current absolute scrollbar offset reported by Ghostty. + /// - len: Visible scrollbar length reported by Ghostty. + /// - Returns: A `GhosttyScrollbar` initialized with the requested state. + private func makeScrollbar(total: UInt64, offset: UInt64, len: UInt64) -> GhosttyScrollbar { + GhosttyScrollbar( + c: ghostty_action_scrollbar_s( + total: total, + offset: offset, + len: len + ) + ) + } + + /// Installs a scrollbar state on the Ghostty surface hosted by a workspace panel. + /// + /// - Parameters: + /// - scrollbar: Scrollbar state to install on the surface. + /// - panelId: Workspace panel whose hosted Ghostty surface should be updated. + /// - workspace: Workspace containing the target panel. + /// - Throws: XCTest unwrap failures when the panel or surface cannot be found. + private func setScrollbar( + _ scrollbar: GhosttyScrollbar, + forPanelId panelId: UUID, + in workspace: Workspace + ) throws { + let panel = try XCTUnwrap(workspace.terminalPanel(for: panelId)) + let surfaceView = try XCTUnwrap(findGhosttySurfaceView(in: panel.hostedView)) + surfaceView.scrollbar = scrollbar + } + + /// Recursively finds the first Ghostty surface in an AppKit view hierarchy. + /// + /// - Parameter view: Root view to inspect. + /// - Returns: The first `GhosttyNSView` found, or `nil` when none exists. + private func findGhosttySurfaceView(in view: NSView) -> GhosttyNSView? { + if let surfaceView = view as? GhosttyNSView { + return surfaceView + } + for subview in view.subviews { + if let surfaceView = findGhosttySurfaceView(in: subview) { + return surfaceView + } + } + return nil + } + private func makeSocketPath(_ name: String) -> String { let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) return URL(fileURLWithPath: NSTemporaryDirectory()) From aa76e9944f287f3ef47bfabfa6c57442a9890452 Mon Sep 17 00:00:00 2001 From: duroy Date: Sat, 6 Jun 2026 17:35:25 +0800 Subject: [PATCH 04/10] fix: address notification anchor review followups --- Sources/GhosttyTerminalView.swift | 3 +- Sources/TerminalController.swift | 1 + Sources/TerminalNotificationStore.swift | 14 ++--- cmuxTests/TerminalAndGhosttyTests.swift | 63 +++++++++++++++++++- cmuxTests/WorkspacePromptSubmitTests.swift | 68 ++++++++++++++++++++++ 5 files changed, 139 insertions(+), 10 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 46e6ba926dfe..b6fb5b277763 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -15293,10 +15293,11 @@ final class GhosttySurfaceScrollView: NSView { scrollbar: GhosttyScrollbar ) -> Bool { let row = Self.notificationOpenScrollRow(for: anchor, scrollbar: scrollbar) + guard surfaceView.performBindingAction("scroll_to_row:\(row)") else { return false } userScrolledAwayFromBottom = row > 0 allowExplicitScrollbarSync = true lastSentRow = row - return surfaceView.performBindingAction("scroll_to_row:\(row)") + return true } /// Applies a queued notification open anchor once Ghostty reports scrollbar readiness. diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ece68763115e..70c2e5718363 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4786,6 +4786,7 @@ class TerminalController { } } let message = messageKeys.lazy.compactMap { self.v2RawString(params, $0) }.first + // tab_id is a workspace id in this API, so it must not be used as a surface fallback. let surfaceId = v2UUID(params, "surface_id") guard let tabManager = v2ResolveWorkspaceOwner(workspaceId) ?? v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 2f3b67af252b..c8c3e98b2ff0 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -822,14 +822,8 @@ struct TerminalNotification: Identifiable, Hashable { } struct TerminalNotificationOpenAnchor: Codable, Hashable, Sendable { + /// Absolute top-row offset reported by Ghostty at prompt submit time. let scrollbarOffset: UInt64 - - /// Creates a notification reopen anchor from Ghostty's absolute scrollbar offset. - /// - /// - Parameter scrollbarOffset: Absolute top-row offset reported by Ghostty at prompt submit time. - init(scrollbarOffset: UInt64) { - self.scrollbarOffset = scrollbarOffset - } } @MainActor @@ -1934,6 +1928,9 @@ final class TerminalNotificationStore: ObservableObject { discardQueuedNotifications: Bool = true ) { if discardQueuedNotifications { TerminalMutationBus.shared.discardPendingNotifications(forTabId: tabId, surfaceId: surfaceId) } + promptSubmitOpenAnchors = promptSubmitOpenAnchors.filter { entry in + !(entry.key.tabId == tabId && entry.key.surfaceId == surfaceId) + } let hadFocusedReadIndicator = focusedReadIndicatorByTabId[tabId].map { $0 == surfaceId } ?? false let hadRestoredWorkspaceUnread = surfaceId == nil && restoredUnreadWorkspaceIds.contains(tabId) var updated: [TerminalNotification] = [] @@ -2000,6 +1997,9 @@ final class TerminalNotificationStore: ObservableObject { func clearNotifications(forTabId tabId: UUID, discardQueuedNotifications: Bool = true) { if discardQueuedNotifications { TerminalMutationBus.shared.discardPendingNotifications(forTabId: tabId) } + promptSubmitOpenAnchors = promptSubmitOpenAnchors.filter { entry in + entry.key.tabId != tabId + } let hadFocusedReadIndicator = focusedReadIndicatorByTabId[tabId] != nil var updated: [TerminalNotification] = [] updated.reserveCapacity(notifications.count) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 9913fb0e7331..5fa50202dc6f 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -3949,14 +3949,16 @@ final class GhosttySurfaceOverlayTests: XCTestCase { private final class BindingActionProbeSurfaceView: GhosttyNSView { private(set) var performedBindingActions: [String] = [] + var bindingActionResults: [Bool] = [] /// Records Ghostty binding actions without sending them to a real surface. /// /// - Parameter action: Ghostty binding action requested by the scroll view. - /// - Returns: `true` so callers treat the action as accepted. + /// - Returns: The next configured result, or `true` when no result is configured. override func performBindingAction(_ action: String) -> Bool { performedBindingActions.append(action) - return true + guard !bindingActionResults.isEmpty else { return true } + return bindingActionResults.removeFirst() } } @@ -4019,6 +4021,63 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertEqual(surfaceView.performedBindingActions, ["scroll_to_row:50"]) } + /// Verifies failed notification-open dispatch does not suppress a later live-scroll dispatch. + func testNotificationOpenAnchorFailureDoesNotMarkRowAsSent() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surfaceView = BindingActionProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + surfaceView.cellSize = CGSize(width: 10, height: 10) + surfaceView.bindingActionResults = [false, true] + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else { + XCTFail("Expected hosted terminal scroll view") + return + } + + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 90, len: 10)] + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + + let anchor = TerminalNotificationOpenAnchor(scrollbarOffset: 40) + XCTAssertFalse(hostedView.scrollToNotificationOpenAnchor(anchor)) + XCTAssertEqual(surfaceView.performedBindingActions, ["scroll_to_row:50"]) + + let row = 50 + let documentHeight = scrollView.documentView?.frame.height ?? scrollView.contentSize.height + let visibleHeight = scrollView.contentView.bounds.height + let targetY = documentHeight - (CGFloat(row) * surfaceView.cellSize.height) - visibleHeight + scrollView.contentView.scroll(to: CGPoint(x: 0, y: max(0, targetY))) + scrollView.reflectScrolledClipView(scrollView.contentView) + NotificationCenter.default.post(name: NSScrollView.didLiveScrollNotification, object: scrollView) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + + XCTAssertEqual(surfaceView.performedBindingActions, ["scroll_to_row:50", "scroll_to_row:50"]) + } + override func tearDown() { GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil for surface in surfacesToRelease.reversed() { diff --git a/cmuxTests/WorkspacePromptSubmitTests.swift b/cmuxTests/WorkspacePromptSubmitTests.swift index 9c2fd39f065a..a5a61674714e 100644 --- a/cmuxTests/WorkspacePromptSubmitTests.swift +++ b/cmuxTests/WorkspacePromptSubmitTests.swift @@ -204,6 +204,74 @@ final class WorkspacePromptSubmitTests: XCTestCase { XCTAssertEqual(notification.openAnchor, anchor) } + /// Verifies workspace notification clearing also removes prompt-submit anchors. + func testClearNotificationsForTabIdRemovesPromptSubmitOpenAnchorsWithoutNotifications() { + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([]) + defer { store.replaceNotificationsForTesting([]) } + + let workspaceId = UUID() + let surfaceId = UUID() + let otherWorkspaceId = UUID() + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 42), + forTabId: workspaceId, + surfaceId: surfaceId + ) + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 64), + forTabId: workspaceId, + surfaceId: nil + ) + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 99), + forTabId: otherWorkspaceId, + surfaceId: nil + ) + + store.clearNotifications(forTabId: workspaceId, discardQueuedNotifications: false) + + XCTAssertNil(store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: surfaceId)) + XCTAssertNil(store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: nil)) + XCTAssertEqual( + store.promptSubmitOpenAnchor(forTabId: otherWorkspaceId, surfaceId: nil)?.scrollbarOffset, + 99 + ) + } + + /// Verifies surface notification clearing removes only the matching prompt-submit anchor. + func testClearNotificationsForSurfaceRemovesOnlyMatchingPromptSubmitOpenAnchor() { + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([]) + defer { store.replaceNotificationsForTesting([]) } + + let workspaceId = UUID() + let firstSurfaceId = UUID() + let secondSurfaceId = UUID() + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 42), + forTabId: workspaceId, + surfaceId: firstSurfaceId + ) + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 64), + forTabId: workspaceId, + surfaceId: secondSurfaceId + ) + + store.clearNotifications( + forTabId: workspaceId, + surfaceId: firstSurfaceId, + discardQueuedNotifications: false + ) + + XCTAssertNil(store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: firstSurfaceId)) + XCTAssertEqual( + store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: secondSurfaceId)?.scrollbarOffset, + 64 + ) + } + func testFeedPromptSubmitEventExtractsToolInputMessage() throws { let manager = TabManager() let first = manager.tabs[0] From 48bae5b68b7a484b8c403e2eb1f6ad628cab8162 Mon Sep 17 00:00:00 2001 From: duroy Date: Sat, 6 Jun 2026 17:57:13 +0800 Subject: [PATCH 05/10] fix: clear notification anchor aliases --- Sources/TerminalNotificationStore.swift | 40 +++++++++++++-- cmuxTests/WorkspacePromptSubmitTests.swift | 59 ++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index c8c3e98b2ff0..3e75bdbd93b3 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1261,6 +1261,26 @@ final class TerminalNotificationStore: ObservableObject { return promptSubmitOpenAnchors[TabSurfaceKey(tabId: tabId, surfaceId: nil)] } + /// Removes prompt-submit anchors for a tab and the supplied surface aliases. + /// + /// - Parameters: + /// - tabId: Workspace identifier whose anchors should be pruned. + /// - surfaceIds: Surface, panel, or Bonsplit alias identifiers to remove. + /// - removesNilFallback: Whether to remove the tab-level fallback anchor. + private func removePromptSubmitOpenAnchors( + forTabId tabId: UUID, + surfaceIds: Set, + removesNilFallback: Bool + ) { + promptSubmitOpenAnchors = promptSubmitOpenAnchors.filter { entry in + guard entry.key.tabId == tabId else { return true } + guard let surfaceId = entry.key.surfaceId else { + return !removesNilFallback + } + return !surfaceIds.contains(surfaceId) + } + } + func clearLatestNotification(forTabId tabId: UUID) { guard let latestNotification = indexes.latestByTabId[tabId] else { return } remove(id: latestNotification.id) @@ -1928,21 +1948,35 @@ final class TerminalNotificationStore: ObservableObject { discardQueuedNotifications: Bool = true ) { if discardQueuedNotifications { TerminalMutationBus.shared.discardPendingNotifications(forTabId: tabId, surfaceId: surfaceId) } - promptSubmitOpenAnchors = promptSubmitOpenAnchors.filter { entry in - !(entry.key.tabId == tabId && entry.key.surfaceId == surfaceId) - } let hadFocusedReadIndicator = focusedReadIndicatorByTabId[tabId].map { $0 == surfaceId } ?? false let hadRestoredWorkspaceUnread = surfaceId == nil && restoredUnreadWorkspaceIds.contains(tabId) var updated: [TerminalNotification] = [] updated.reserveCapacity(notifications.count) var idsToClear: [String] = [] + var promptSubmitOpenAnchorSurfaceIds = Set() + if let surfaceId { + promptSubmitOpenAnchorSurfaceIds.insert(surfaceId) + } + var removesPromptSubmitOpenAnchorNilFallback = false for notification in notifications { if notification.matches(tabId: tabId, surfaceId: surfaceId) { idsToClear.append(notification.id.uuidString) + removesPromptSubmitOpenAnchorNilFallback = true + if let surfaceId = notification.surfaceId { + promptSubmitOpenAnchorSurfaceIds.insert(surfaceId) + } + if let panelId = notification.panelId { + promptSubmitOpenAnchorSurfaceIds.insert(panelId) + } } else { updated.append(notification) } } + removePromptSubmitOpenAnchors( + forTabId: tabId, + surfaceIds: promptSubmitOpenAnchorSurfaceIds, + removesNilFallback: removesPromptSubmitOpenAnchorNilFallback + ) guard !idsToClear.isEmpty || hadFocusedReadIndicator || hadRestoredWorkspaceUnread else { return } if !idsToClear.isEmpty { replaceNotificationsForClear(updated) diff --git a/cmuxTests/WorkspacePromptSubmitTests.swift b/cmuxTests/WorkspacePromptSubmitTests.swift index a5a61674714e..71960863f627 100644 --- a/cmuxTests/WorkspacePromptSubmitTests.swift +++ b/cmuxTests/WorkspacePromptSubmitTests.swift @@ -272,6 +272,65 @@ final class WorkspacePromptSubmitTests: XCTestCase { ) } + /// Verifies surface notification clearing removes panel, surface alias, and nil-fallback anchors. + func testClearNotificationsForSurfaceRemovesPromptSubmitOpenAnchorAliases() { + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([]) + defer { store.replaceNotificationsForTesting([]) } + + let workspaceId = UUID() + let panelId = UUID() + let bonsplitSurfaceId = UUID() + let otherSurfaceId = UUID() + store.replaceNotificationsForTesting([ + TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: bonsplitSurfaceId, + panelId: panelId, + title: "Agent finished", + subtitle: "codex", + body: "Done", + createdAt: Date(), + isRead: false + ) + ]) + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 42), + forTabId: workspaceId, + surfaceId: panelId + ) + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 43), + forTabId: workspaceId, + surfaceId: bonsplitSurfaceId + ) + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 44), + forTabId: workspaceId, + surfaceId: nil + ) + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 64), + forTabId: workspaceId, + surfaceId: otherSurfaceId + ) + + store.clearNotifications( + forTabId: workspaceId, + surfaceId: panelId, + discardQueuedNotifications: false + ) + + XCTAssertNil(store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: panelId)) + XCTAssertNil(store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: bonsplitSurfaceId)) + XCTAssertNil(store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: nil)) + XCTAssertEqual( + store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: otherSurfaceId)?.scrollbarOffset, + 64 + ) + } + func testFeedPromptSubmitEventExtractsToolInputMessage() throws { let manager = TabManager() let first = manager.tabs[0] From e3f5d038f0a511303447851449559cb1620a35a6 Mon Sep 17 00:00:00 2001 From: duroy Date: Tue, 9 Jun 2026 14:08:23 +0800 Subject: [PATCH 06/10] fix(settings): sync sidebar selection while scrolling --- .../Navigation/SettingsSidebarEntryRow.swift | 4 + .../SettingsVisibleSectionResolver.swift | 123 ++++++++++++++++++ .../Scene/SettingsWindowScene.swift | 57 +++++++- .../Sections/BrowserSection.swift | 1 + .../SettingsVisibleSectionResolverTests.swift | 82 ++++++++++++ 5 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsVisibleSectionResolver.swift create mode 100644 Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsVisibleSectionResolverTests.swift diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSidebarEntryRow.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSidebarEntryRow.swift index 2ee8968ce0a4..85258e218a14 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSidebarEntryRow.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSidebarEntryRow.swift @@ -9,9 +9,11 @@ import SwiftUI /// than wrap and inflate row height. @MainActor struct SettingsSidebarEntryRow: View { + let id: String let title: String let symbolName: String let subtitle: String? + let isSelected: Bool var body: some View { HStack(spacing: 10) { @@ -32,5 +34,7 @@ struct SettingsSidebarEntryRow: View { } } .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("SettingsSidebarEntry.\(id)") + .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsVisibleSectionResolver.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsVisibleSectionResolver.swift new file mode 100644 index 000000000000..6662eed84b96 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsVisibleSectionResolver.swift @@ -0,0 +1,123 @@ +import CoreGraphics +import SwiftUI + +enum SettingsSectionVisibilityCoordinateSpace { + static let name = "SettingsSectionVisibilityCoordinateSpace" +} + +struct SettingsSectionFramePreferenceKey: PreferenceKey { + static let defaultValue: [SettingsSectionID: CGRect] = [:] + + /// Merges section frames reported by every visibility marker. + /// + /// - Parameters: + /// - value: Current aggregate section frame map. + /// - nextValue: Next lazily produced section frame map from SwiftUI. + static func reduce( + value: inout [SettingsSectionID: CGRect], + nextValue: () -> [SettingsSectionID: CGRect] + ) { + value.merge(nextValue()) { _, newValue in newValue } + } +} + +struct SettingsSectionVisibilityMarker: View { + let section: SettingsSectionID + + var body: some View { + GeometryReader { proxy in + Color.clear.preference( + key: SettingsSectionFramePreferenceKey.self, + value: [section: proxy.frame(in: .named(SettingsSectionVisibilityCoordinateSpace.name))] + ) + } + } +} + +extension View { + /// Reports this view's frame as the scroll-position marker for a settings section. + /// + /// - Parameter section: Section represented by the view. + /// - Returns: A view that publishes its frame through `SettingsSectionFramePreferenceKey`. + func settingsSectionVisibility(_ section: SettingsSectionID) -> some View { + background(SettingsSectionVisibilityMarker(section: section)) + } +} + +struct SettingsVisibleSectionResolver: Sendable { + struct Configuration: Sendable { + static let defaultActivationY: CGFloat = 0 + + let activationY: CGFloat + + /// Creates a resolver configuration. + /// + /// - Parameter activationY: Vertical viewport coordinate that decides when + /// a section becomes active. A section is active after its top edge + /// crosses this coordinate. + init(activationY: CGFloat = Self.defaultActivationY) { + self.activationY = activationY + } + } + + /// Resolves the section that should be selected for the current scroll position. + /// + /// - Parameters: + /// - frames: Current section frames in the settings detail scroll coordinate space. + /// - orderedSections: Sections in visual scroll order. + /// - configuration: Resolver options, including the activation line. + /// - Returns: The active section, or `nil` when no tracked frames are available. + static func visibleSection( + in frames: [SettingsSectionID: CGRect], + orderedSections: [SettingsSectionID] = SettingsSectionID.allCases, + configuration: Configuration = Configuration() + ) -> SettingsSectionID? { + let orderedFrames = orderedSections.enumerated().compactMap { index, section -> SectionFrame? in + guard let frame = frames[section] else { return nil } + return SectionFrame(index: index, section: section, frame: frame) + } + + guard !orderedFrames.isEmpty else { return nil } + + let containingFrames = orderedFrames.filter { + $0.frame.minY <= configuration.activationY && $0.frame.maxY >= configuration.activationY + } + if let active = nearestFrameToActivationLine(in: containingFrames) { + return active.section + } + + let crossedFrames = orderedFrames.filter { $0.frame.minY <= configuration.activationY } + if let active = nearestFrameToActivationLine(in: crossedFrames) { + return active.section + } + + return orderedFrames.min(by: { lhs, rhs in + if lhs.frame.minY == rhs.frame.minY { + return lhs.index < rhs.index + } + return lhs.frame.minY < rhs.frame.minY + })?.section + } + + /// Selects the frame whose top edge is closest to the activation line. + /// + /// - Parameter frames: Section frames already filtered to eligible candidates. + /// - Returns: The nearest eligible frame, or `nil` when no candidates exist. + private static func nearestFrameToActivationLine(in frames: [SectionFrame]) -> SectionFrame? { + if let active = frames.max(by: { lhs, rhs in + if lhs.frame.minY == rhs.frame.minY { + return lhs.index < rhs.index + } + return lhs.frame.minY < rhs.frame.minY + }) { + return active + } + return nil + } + + private struct SectionFrame { + let index: Int + let section: SettingsSectionID + let frame: CGRect + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift index 8b4b55f4a8af..ca6c540a99fc 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift @@ -70,6 +70,13 @@ public struct SettingsWindowRoot: View { // and re-checked inside the scheduled `Task { @MainActor in ... }`, // so only the most recent request actually scrolls. @State private var settingsNavigationGeneration: Int = 0 + // Prevents the reverse "visible section -> sidebar selection" sync from + // racing the restored or clicked navigation target while `scrollTo` is + // still settling. It starts true so first-layout frame reports cannot + // overwrite the SceneStorage-restored section before `onAppear` scrolls + // there. + @State private var visibleSectionSyncSuppressed: Bool = true + @State private var latestSectionFrames: [SettingsSectionID: CGRect] = [:] // Drives the "flash the navigated-to row" affordance the legacy // settings window had. When the user clicks a search hit, the target // row pulses an accent border for a few seconds so the eye can find @@ -146,6 +153,7 @@ public struct SettingsWindowRoot: View { // selected. guard newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } selectedSidebarEntryID = sectionEntryID(for: selectedSection) + syncSidebarSelectionToVisibleSection(latestSectionFrames) } } @@ -187,9 +195,11 @@ public struct SettingsWindowRoot: View { } else { ForEach(matches) { entry in SettingsSidebarEntryRow( + id: entry.id, title: entry.title, symbolName: entry.symbolName, - subtitle: subtitle(for: entry) + subtitle: subtitle(for: entry), + isSelected: selectedSidebarEntryID == entry.id ) .tag(entry.id) } @@ -341,7 +351,13 @@ public struct SettingsWindowRoot: View { .padding(.top, 20) .padding(.bottom, 20) } + .coordinateSpace(name: SettingsSectionVisibilityCoordinateSpace.name) + .accessibilityIdentifier("SettingsDetailScrollView") .toggleStyle(.switch) + .onPreferenceChange(SettingsSectionFramePreferenceKey.self) { frames in + latestSectionFrames = frames + syncSidebarSelectionToVisibleSection(frames) + } .onAppear { // Legacy SettingsView.onAppear scrolls to the restored // section so reopening the Settings window lands on @@ -392,6 +408,7 @@ public struct SettingsWindowRoot: View { let sectionID = self.anchorID(for: target) settingsNavigationGeneration += 1 let navigationGeneration = settingsNavigationGeneration + visibleSectionSyncSuppressed = true // Arm (or clear) the highlight before the scroll so the pulse is // already live when the target lands in view. A section hit // (anchorID == sectionID) highlights the section header; a row @@ -422,6 +439,30 @@ public struct SettingsWindowRoot: View { Task { @MainActor in guard navigationGeneration == settingsNavigationGeneration else { return } proxy.scrollTo(anchorID, anchor: anchor) + await Task.yield() + guard navigationGeneration == settingsNavigationGeneration else { return } + visibleSectionSyncSuppressed = false + } + } + + /// Syncs the sidebar's selected row to the currently visible detail section. + /// + /// - Parameter frames: Section frames reported in the settings detail scroll + /// coordinate space. + private func syncSidebarSelectionToVisibleSection(_ frames: [SettingsSectionID: CGRect]) { + guard !visibleSectionSyncSuppressed else { return } + // Search results can select a specific setting row, not just a section. + // Keep that deep selection stable until the search query is cleared. + guard !isSearching else { return } + guard let visibleSection = SettingsVisibleSectionResolver.visibleSection(in: frames) else { return } + + if selectedSectionRaw != visibleSection.rawValue { + selectedSectionRaw = visibleSection.rawValue + } + + let sidebarEntryID = sectionEntryID(for: visibleSection) + if selectedSidebarEntryID != sidebarEntryID { + selectedSidebarEntryID = sidebarEntryID } } @@ -436,6 +477,7 @@ public struct SettingsWindowRoot: View { catalog: catalog, accountFlow: accountFlow ) + .settingsSectionVisibility(.account) .id(anchorID(for: .account)) AppSection( @@ -443,6 +485,7 @@ public struct SettingsWindowRoot: View { catalog: catalog, hostActions: hostActions ) + .settingsSectionVisibility(.app) .id(anchorID(for: .app)) TerminalSection( @@ -451,18 +494,23 @@ public struct SettingsWindowRoot: View { catalog: catalog, hostActions: hostActions ) + .settingsSectionVisibility(.terminal) .id(anchorID(for: .terminal)) TextBoxSection(defaultsStore: defaultsStore, catalog: catalog) + .settingsSectionVisibility(.textBox) .id(anchorID(for: .textBox)) MobileSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions) + .settingsSectionVisibility(.mobile) .id(anchorID(for: .mobile)) SidebarSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions) + .settingsSectionVisibility(.sidebarAppearance) .id(anchorID(for: .sidebarAppearance)) BetaFeaturesSection(defaultsStore: defaultsStore, catalog: catalog) + .settingsSectionVisibility(.betaFeatures) .id(anchorID(for: .betaFeatures)) AutomationSection( @@ -472,6 +520,7 @@ public struct SettingsWindowRoot: View { catalog: catalog, errorLog: runtime.errorLog ) + .settingsSectionVisibility(.automation) .id(anchorID(for: .automation)) BrowserSection( @@ -480,6 +529,7 @@ public struct SettingsWindowRoot: View { hostActions: hostActions, importAnchorID: anchorID(for: .browserImport) ) + .settingsSectionVisibility(.browser) .id(anchorID(for: .browser)) GlobalHotkeySection( @@ -488,6 +538,7 @@ public struct SettingsWindowRoot: View { catalog: catalog, errorLog: runtime.errorLog ) + .settingsSectionVisibility(.globalHotkey) .id(anchorID(for: .globalHotkey)) KeyboardShortcutsSection( @@ -496,6 +547,7 @@ public struct SettingsWindowRoot: View { errorLog: runtime.errorLog, hostActions: hostActions ) + .settingsSectionVisibility(.keyboardShortcuts) .id(anchorID(for: .keyboardShortcuts)) WorkspaceColorsSection( @@ -504,9 +556,11 @@ public struct SettingsWindowRoot: View { catalog: catalog, errorLog: runtime.errorLog ) + .settingsSectionVisibility(.workspaceColors) .id(anchorID(for: .workspaceColors)) SettingsJSONSection(jsonStore: jsonStore, hostActions: hostActions) + .settingsSectionVisibility(.settingsJSON) .id(anchorID(for: .settingsJSON)) ResetSection( @@ -514,6 +568,7 @@ public struct SettingsWindowRoot: View { jsonStore: jsonStore, catalog: catalog ) + .settingsSectionVisibility(.reset) .id(anchorID(for: .reset)) } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift index 334e422a49f0..88cfe6e71789 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift @@ -268,6 +268,7 @@ public struct BrowserSection: View { importHintModel: importHint, onImport: { hostActions.openBrowserImportFlow() } ) + .settingsSectionVisibility(.browserImport) .id(importAnchorID ?? "section:browserImport.inline") .settingsSearchHighlight([importAnchorID, "setting:browserImport:import-data"].compactMap { $0 }) SettingsCardDivider() diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsVisibleSectionResolverTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsVisibleSectionResolverTests.swift new file mode 100644 index 000000000000..47b106548c88 --- /dev/null +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsVisibleSectionResolverTests.swift @@ -0,0 +1,82 @@ +import CoreGraphics +import Testing +@testable import CmuxSettingsUI + +@Suite("SettingsVisibleSectionResolver") +struct SettingsVisibleSectionResolverTests { + @Test func returnsNilWhenNoSectionFramesExist() { + let section = SettingsVisibleSectionResolver.visibleSection(in: [:]) + #expect(section == nil) + } + + @Test func selectsFirstUpcomingSectionBeforeAnySectionCrossesActivationLine() { + let frames: [SettingsSectionID: CGRect] = [ + .account: frame(y: 20), + .app: frame(y: 260), + ] + + let section = SettingsVisibleSectionResolver.visibleSection(in: frames) + + #expect(section == .account) + } + + @Test func selectsNearestSectionWhoseTopCrossedActivationLine() { + let frames: [SettingsSectionID: CGRect] = [ + .account: frame(y: -520), + .app: frame(y: -12), + .terminal: frame(y: 340), + ] + + let section = SettingsVisibleSectionResolver.visibleSection(in: frames) + + #expect(section == .app) + } + + @Test func supportsInlineBrowserImportSectionWhenItsMarkerCrossesTop() { + let frames: [SettingsSectionID: CGRect] = [ + .browser: frame(y: -420, height: 1_000), + .browserImport: frame(y: -8), + .globalHotkey: frame(y: 180), + ] + + let section = SettingsVisibleSectionResolver.visibleSection(in: frames) + + #expect(section == .browserImport) + } + + @Test func fallsBackToParentBrowserSectionAfterInlineImportScrollsPastTop() { + let frames: [SettingsSectionID: CGRect] = [ + .browser: frame(y: -520, height: 1_000), + .browserImport: frame(y: -180), + .globalHotkey: frame(y: 240), + ] + + let section = SettingsVisibleSectionResolver.visibleSection(in: frames) + + #expect(section == .browser) + } + + @Test func customActivationLineSelectsSectionBeforeItReachesViewportTop() { + let configuration = SettingsVisibleSectionResolver.Configuration(activationY: 40) + let frames: [SettingsSectionID: CGRect] = [ + .account: frame(y: -220), + .app: frame(y: 24), + .terminal: frame(y: 280), + ] + + let section = SettingsVisibleSectionResolver.visibleSection( + in: frames, + configuration: configuration + ) + + #expect(section == .app) + } + + /// Builds a fixed-size section frame for resolver tests. + /// + /// - Parameter y: Top-edge y coordinate in the scroll coordinate space. + /// - Returns: A rectangle with stable dimensions and the supplied top edge. + private func frame(y: CGFloat, height: CGFloat = 100) -> CGRect { + CGRect(x: 0, y: y, width: 100, height: height) + } +} From c574b5f7a6977f94f9aa5f2cd919f67d92fd1de0 Mon Sep 17 00:00:00 2001 From: duroy Date: Tue, 9 Jun 2026 14:12:35 +0800 Subject: [PATCH 07/10] test(notifications): cover turn-start notification anchors --- cmuxTests/TerminalAndGhosttyTests.swift | 9 +++++++++ cmuxTests/TerminalNotificationSocketActionTests.swift | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 5fa50202dc6f..68e34414bbe9 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -3976,6 +3976,15 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) } + /// Verifies prompt-submit anchors point at the next terminal output row. + func testNotificationOpenAnchorCapturesTurnStartInsteadOfViewportTop() { + let surfaceView = BindingActionProbeSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + surfaceView.scrollbar = makeScrollbar(total: 240, offset: 64, len: 20) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + + XCTAssertEqual(hostedView.notificationOpenAnchor()?.scrollbarOffset, 240) + } + func testNotificationOpenAnchorConvertsAbsoluteScrollOffsetToGhosttyRow() { let anchor = TerminalNotificationOpenAnchor(scrollbarOffset: 80) let scrollbar = makeScrollbar(total: 150, offset: 130, len: 20) diff --git a/cmuxTests/TerminalNotificationSocketActionTests.swift b/cmuxTests/TerminalNotificationSocketActionTests.swift index 2a60235658c1..658065b0c08d 100644 --- a/cmuxTests/TerminalNotificationSocketActionTests.swift +++ b/cmuxTests/TerminalNotificationSocketActionTests.swift @@ -191,7 +191,7 @@ final class TerminalNotificationSocketActionTests: XCTestCase { XCTAssertEqual(openAnchor["scrollbar_offset"] as? UInt64, anchor.scrollbarOffset) } - /// Verifies prompt-submit requests without a surface id record an anchor for the focused terminal. + /// Verifies prompt-submit requests without a surface id record the agent turn-start anchor. func testPromptSubmitWithoutSurfaceIdRecordsAnchorForFocusedTerminalNotification() async throws { let fixture = try makeSocketFixture(name: "prompt-submit-anchor") defer { fixture.cleanup() } @@ -218,7 +218,7 @@ final class TerminalNotificationSocketActionTests: XCTestCase { body: "Done" ) let notification = try XCTUnwrap(fixture.store.notifications.first) - XCTAssertEqual(notification.openAnchor?.scrollbarOffset, scrollbar.offset) + XCTAssertEqual(notification.openAnchor?.scrollbarOffset, scrollbar.total) } func testNotificationJumpToUnreadOpensLatestUnreadAndNoOpsWhenNoneRemain() async throws { From cb3dc5f9c3c494f0bb92ac5d05d4dc4e4b6561c8 Mon Sep 17 00:00:00 2001 From: duroy Date: Tue, 9 Jun 2026 14:16:31 +0800 Subject: [PATCH 08/10] fix(notifications): open terminal notifications at turn start --- Sources/AppDelegate.swift | 4 ++-- Sources/GhosttyTerminalView.swift | 14 +++++++------- Sources/Panels/TerminalPanel.swift | 6 +++--- Sources/TerminalNotificationStore.swift | 10 +++++----- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index aa88cea9d8e3..367d17c16a5b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -16210,10 +16210,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - /// Scrolls a focused notification target back to its captured prompt-submit anchor. + /// Scrolls a focused notification target back to its captured prompt-submit turn-start anchor. /// /// - Parameters: - /// - openAnchor: Optional scrollback anchor stored on the notification. + /// - openAnchor: Optional turn-start anchor stored on the notification. /// - tabId: Workspace identifier for the notification target. /// - surfaceId: Optional panel or surface identifier focused for the notification. /// - tabManager: Tab manager that owns the focused workspace. diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b6fb5b277763..17434c08c89c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -15242,18 +15242,18 @@ final class GhosttySurfaceScrollView: NSView { layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) } - /// Returns the current absolute scroll offset for later notification reopening. + /// Returns the next terminal output row for later notification reopening. /// /// - Returns: A notification open anchor, or `nil` until Ghostty reports scrollbar state. func notificationOpenAnchor() -> TerminalNotificationOpenAnchor? { guard let scrollbar = surfaceView.scrollbar else { return nil } - return TerminalNotificationOpenAnchor(scrollbarOffset: scrollbar.offset) + return TerminalNotificationOpenAnchor(scrollbarOffset: scrollbar.total) } - /// Converts a saved absolute scroll offset into Ghostty's bottom-relative scroll row. + /// Converts a saved absolute turn-start row into Ghostty's bottom-relative scroll row. /// /// - Parameters: - /// - anchor: Prompt-submit anchor containing the absolute top-row offset. + /// - anchor: Prompt-submit anchor containing the absolute turn-start row. /// - scrollbar: Current Ghostty scrollbar state. /// - Returns: Bottom-relative row count suitable for `scroll_to_row:`. static func notificationOpenScrollRow( @@ -15267,9 +15267,9 @@ final class GhosttySurfaceScrollView: NSView { return row > UInt64(Int.max) ? Int.max : Int(row) } - /// Scrolls Ghostty back to the prompt-submit anchor for a reopened notification. + /// Scrolls Ghostty back to the prompt-submit turn-start anchor for a reopened notification. /// - /// - Parameter anchor: The absolute scrollback anchor captured when the prompt was submitted. + /// - Parameter anchor: The absolute turn-start anchor captured when the prompt was submitted. /// - Returns: `true` if the scroll action was sent to Ghostty or queued for the next scrollbar update. @discardableResult func scrollToNotificationOpenAnchor(_ anchor: TerminalNotificationOpenAnchor) -> Bool { @@ -15284,7 +15284,7 @@ final class GhosttySurfaceScrollView: NSView { /// Applies a notification open anchor using a known Ghostty scrollbar state. /// /// - Parameters: - /// - anchor: The absolute scrollback anchor captured when the prompt was submitted. + /// - anchor: The absolute turn-start anchor captured when the prompt was submitted. /// - scrollbar: Current Ghostty scrollbar state used for row conversion. /// - Returns: `true` if Ghostty accepted the scroll binding action. @discardableResult diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index a2e0255e11fd..139ea6a6a7ba 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -700,16 +700,16 @@ final class TerminalPanel: Panel, ObservableObject { return surface.performBindingAction(action) } - /// Returns the current scroll position to use when reopening future notifications. + /// Returns the next terminal output row to use when reopening future notifications. /// /// - Returns: A notification open anchor for this terminal, if Ghostty has reported scroll state. func notificationOpenAnchor() -> TerminalNotificationOpenAnchor? { hostedView.notificationOpenAnchor() } - /// Scrolls the terminal back to a prompt-submit anchor after notification focus. + /// Scrolls the terminal back to a prompt-submit turn-start anchor after notification focus. /// - /// - Parameter anchor: The absolute scrollback anchor captured when the prompt was submitted. + /// - Parameter anchor: The absolute turn-start anchor captured when the prompt was submitted. /// - Returns: `true` if the scroll action was sent to Ghostty. @discardableResult func scrollToNotificationOpenAnchor(_ anchor: TerminalNotificationOpenAnchor) -> Bool { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 3e75bdbd93b3..b212499f49a6 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -822,7 +822,7 @@ struct TerminalNotification: Identifiable, Hashable { } struct TerminalNotificationOpenAnchor: Codable, Hashable, Sendable { - /// Absolute top-row offset reported by Ghostty at prompt submit time. + /// Absolute terminal row where the agent turn output begins. let scrollbarOffset: UInt64 } @@ -1232,10 +1232,10 @@ final class TerminalNotificationStore: ObservableObject { notifications.filter { $0.matches(tabId: tabId, surfaceId: surfaceId) } } - /// Records the terminal scroll position that a future notification should reopen to. + /// Records the terminal turn-start row that a future notification should reopen to. /// /// - Parameters: - /// - anchor: Absolute terminal scrollback anchor captured at prompt submit time. + /// - anchor: Absolute terminal turn-start anchor captured at prompt submit time. /// - tabId: Workspace identifier that owns the terminal surface. /// - surfaceId: Terminal surface or panel identifier within the workspace. func recordPromptSubmitOpenAnchor( @@ -1246,12 +1246,12 @@ final class TerminalNotificationStore: ObservableObject { promptSubmitOpenAnchors[TabSurfaceKey(tabId: tabId, surfaceId: surfaceId)] = anchor } - /// Returns the prompt-submit anchor for a workspace/surface notification target. + /// Returns the prompt-submit turn-start anchor for a workspace/surface notification target. /// /// - Parameters: /// - tabId: Workspace identifier that owns the notification. /// - surfaceId: Terminal surface or panel identifier for the notification. - /// - Returns: The most recent prompt-submit anchor for the target, if one was recorded. + /// - Returns: The most recent prompt-submit turn-start anchor for the target, if one was recorded. func promptSubmitOpenAnchor(forTabId tabId: UUID, surfaceId: UUID?) -> TerminalNotificationOpenAnchor? { let target = TabSurfaceKey(tabId: tabId, surfaceId: surfaceId) if let anchor = promptSubmitOpenAnchors[target] { From cdf51a4243392c027ccb1b8d46ef24cd18f2a8eb Mon Sep 17 00:00:00 2001 From: duroy Date: Tue, 9 Jun 2026 15:22:27 +0800 Subject: [PATCH 09/10] fix: address notification anchor review feedback --- .../SettingsSectionFramePreferenceKey.swift | 18 +++ ...ingsSectionVisibilityCoordinateSpace.swift | 3 + .../SettingsSectionVisibilityMarker.swift | 24 ++++ .../SettingsVisibleSectionResolver.swift | 44 ------- Sources/AppDelegate.swift | 10 +- Sources/PromptSubmitOpenAnchorStore.swift | 93 ++++++++++++++ Sources/TerminalNotificationStore.swift | 113 ++++++++++++------ cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/WorkspacePromptSubmitTests.swift | 88 ++++++++++++++ 9 files changed, 315 insertions(+), 82 deletions(-) create mode 100644 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionFramePreferenceKey.swift create mode 100644 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityCoordinateSpace.swift create mode 100644 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityMarker.swift create mode 100644 Sources/PromptSubmitOpenAnchorStore.swift diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionFramePreferenceKey.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionFramePreferenceKey.swift new file mode 100644 index 000000000000..ea3f39aeda2e --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionFramePreferenceKey.swift @@ -0,0 +1,18 @@ +import CoreGraphics +import SwiftUI + +struct SettingsSectionFramePreferenceKey: PreferenceKey { + static let defaultValue: [SettingsSectionID: CGRect] = [:] + + /// Merges section frames reported by every visibility marker. + /// + /// - Parameters: + /// - value: Current aggregate section frame map. + /// - nextValue: Next lazily produced section frame map from SwiftUI. + static func reduce( + value: inout [SettingsSectionID: CGRect], + nextValue: () -> [SettingsSectionID: CGRect] + ) { + value.merge(nextValue()) { _, newValue in newValue } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityCoordinateSpace.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityCoordinateSpace.swift new file mode 100644 index 000000000000..9d0a417bcb3e --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityCoordinateSpace.swift @@ -0,0 +1,3 @@ +enum SettingsSectionVisibilityCoordinateSpace { + static let name = "SettingsSectionVisibilityCoordinateSpace" +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityMarker.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityMarker.swift new file mode 100644 index 000000000000..280a2e41c80e --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityMarker.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct SettingsSectionVisibilityMarker: View { + let section: SettingsSectionID + + var body: some View { + GeometryReader { proxy in + Color.clear.preference( + key: SettingsSectionFramePreferenceKey.self, + value: [section: proxy.frame(in: .named(SettingsSectionVisibilityCoordinateSpace.name))] + ) + } + } +} + +extension View { + /// Reports this view's frame as the scroll-position marker for a settings section. + /// + /// - Parameter section: Section represented by the view. + /// - Returns: A view that publishes its frame through `SettingsSectionFramePreferenceKey`. + func settingsSectionVisibility(_ section: SettingsSectionID) -> some View { + background(SettingsSectionVisibilityMarker(section: section)) + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsVisibleSectionResolver.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsVisibleSectionResolver.swift index 6662eed84b96..5fdc95737d15 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsVisibleSectionResolver.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsVisibleSectionResolver.swift @@ -1,48 +1,4 @@ import CoreGraphics -import SwiftUI - -enum SettingsSectionVisibilityCoordinateSpace { - static let name = "SettingsSectionVisibilityCoordinateSpace" -} - -struct SettingsSectionFramePreferenceKey: PreferenceKey { - static let defaultValue: [SettingsSectionID: CGRect] = [:] - - /// Merges section frames reported by every visibility marker. - /// - /// - Parameters: - /// - value: Current aggregate section frame map. - /// - nextValue: Next lazily produced section frame map from SwiftUI. - static func reduce( - value: inout [SettingsSectionID: CGRect], - nextValue: () -> [SettingsSectionID: CGRect] - ) { - value.merge(nextValue()) { _, newValue in newValue } - } -} - -struct SettingsSectionVisibilityMarker: View { - let section: SettingsSectionID - - var body: some View { - GeometryReader { proxy in - Color.clear.preference( - key: SettingsSectionFramePreferenceKey.self, - value: [section: proxy.frame(in: .named(SettingsSectionVisibilityCoordinateSpace.name))] - ) - } - } -} - -extension View { - /// Reports this view's frame as the scroll-position marker for a settings section. - /// - /// - Parameter section: Section represented by the view. - /// - Returns: A view that publishes its frame through `SettingsSectionFramePreferenceKey`. - func settingsSectionVisibility(_ section: SettingsSectionID) -> some View { - background(SettingsSectionVisibilityMarker(section: section)) - } -} struct SettingsVisibleSectionResolver: Sendable { struct Configuration: Sendable { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 367d17c16a5b..eb0af82714fd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15545,6 +15545,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: + let openAnchor = TerminalNotificationOpenAnchor( + userInfo: response.notification.request.content.userInfo + ) let notificationId: UUID? = { if let id = UUID(uuidString: response.notification.request.identifier) { return id @@ -15565,7 +15568,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } DispatchQueue.main.async { - _ = self.openNotification(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + _ = self.openNotification( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + openAnchor: openAnchor + ) } case UNNotificationDismissActionIdentifier: DispatchQueue.main.async { diff --git a/Sources/PromptSubmitOpenAnchorStore.swift b/Sources/PromptSubmitOpenAnchorStore.swift new file mode 100644 index 000000000000..851ff5db4531 --- /dev/null +++ b/Sources/PromptSubmitOpenAnchorStore.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Stores prompt-submit turn-start anchors by workspace and optional surface. +struct PromptSubmitOpenAnchorStore { + private var anchors: [TabSurfaceKey: TerminalNotificationOpenAnchor] = [:] + + /// Returns whether the store has no recorded anchors. + var isEmpty: Bool { + anchors.isEmpty + } + + /// Records a turn-start anchor for a workspace and optional surface. + /// + /// - Parameters: + /// - anchor: Absolute terminal turn-start anchor captured at prompt submit time. + /// - tabId: Workspace identifier that owns the terminal surface. + /// - surfaceId: Terminal surface or panel identifier within the workspace. + mutating func record( + _ anchor: TerminalNotificationOpenAnchor, + forTabId tabId: UUID, + surfaceId: UUID? + ) { + anchors[TabSurfaceKey(tabId: tabId, surfaceId: surfaceId)] = anchor + } + + /// Returns the best turn-start anchor for a workspace and optional surface. + /// + /// - Parameters: + /// - tabId: Workspace identifier that owns the notification. + /// - surfaceId: Terminal surface or panel identifier for the notification. + /// - Returns: A surface-specific anchor, or the tab-level fallback when available. + func anchor(forTabId tabId: UUID, surfaceId: UUID?) -> TerminalNotificationOpenAnchor? { + let target = TabSurfaceKey(tabId: tabId, surfaceId: surfaceId) + if let anchor = anchors[target] { + return anchor + } + guard surfaceId != nil else { return nil } + return anchors[TabSurfaceKey(tabId: tabId, surfaceId: nil)] + } + + /// Removes anchors for a tab and the supplied surface aliases. + /// + /// - Parameters: + /// - tabId: Workspace identifier whose anchors should be pruned. + /// - surfaceIds: Surface, panel, or Bonsplit alias identifiers to remove. + /// - removesNilFallback: Whether to remove the tab-level fallback anchor. + mutating func remove( + forTabId tabId: UUID, + surfaceIds: Set, + removesNilFallback: Bool + ) { + anchors = anchors.filter { entry in + guard entry.key.tabId == tabId else { return true } + guard let surfaceId = entry.key.surfaceId else { + return !removesNilFallback + } + return !surfaceIds.contains(surfaceId) + } + } + + /// Removes all anchors associated with a workspace. + /// + /// - Parameter tabId: Workspace identifier whose anchors should be removed. + mutating func removeAll(forTabId tabId: UUID) { + anchors = anchors.filter { entry in + entry.key.tabId != tabId + } + } + + /// Moves a surface anchor from one workspace to another. + /// + /// - Parameters: + /// - sourceTabId: Workspace identifier that currently owns the anchor. + /// - destinationTabId: Workspace identifier that should receive the anchor. + /// - surfaceId: Surface identifier being rebound. + mutating func rebindSurface( + fromTabId sourceTabId: UUID, + toTabId destinationTabId: UUID, + surfaceId: UUID + ) { + let sourceKey = TabSurfaceKey(tabId: sourceTabId, surfaceId: surfaceId) + guard let anchor = anchors.removeValue(forKey: sourceKey) else { return } + let destinationKey = TabSurfaceKey(tabId: destinationTabId, surfaceId: surfaceId) + if anchors[destinationKey] == nil { + anchors[destinationKey] = anchor + } + } + + /// Removes every recorded prompt-submit anchor. + mutating func removeAll() { + anchors.removeAll() + } +} diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index b212499f49a6..9345e68ba0a5 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -826,13 +826,39 @@ struct TerminalNotificationOpenAnchor: Codable, Hashable, Sendable { let scrollbarOffset: UInt64 } -@MainActor -final class TerminalNotificationStore: ObservableObject { - private struct TabSurfaceKey: Hashable { - let tabId: UUID - let surfaceId: UUID? +extension TerminalNotificationOpenAnchor { + static let scrollbarOffsetUserInfoKey = "openAnchorScrollbarOffset" + + /// Returns the notification user-info payload for this anchor. + var userInfo: [AnyHashable: Any] { + [Self.scrollbarOffsetUserInfoKey: String(scrollbarOffset)] + } + + /// Creates an anchor from a notification user-info dictionary. + /// + /// - Parameter userInfo: User-info dictionary attached to a delivered notification. + init?(userInfo: [AnyHashable: Any]) { + let rawValue = userInfo[Self.scrollbarOffsetUserInfoKey] + if let value = rawValue as? String, + let scrollbarOffset = UInt64(value) { + self.init(scrollbarOffset: scrollbarOffset) + return + } + if let value = rawValue as? NSNumber { + self.init(scrollbarOffset: value.uint64Value) + return + } + return nil } +} + +struct TabSurfaceKey: Hashable { + let tabId: UUID + let surfaceId: UUID? +} +@MainActor +final class TerminalNotificationStore: ObservableObject { private struct NotificationIndexes { var unreadCount = 0 var unreadCountByTabId: [UUID: Int] = [:] @@ -917,7 +943,7 @@ final class TerminalNotificationStore: ObservableObject { private static let notificationHookFailureThrottle: TimeInterval = 300 private var lastNotificationDateByCooldownKey: [String: Date] = [:] private var lastNotificationHookFailureDateByKey: [NotificationHookFailureThrottleKey: Date] = [:] - private var promptSubmitOpenAnchors: [TabSurfaceKey: TerminalNotificationOpenAnchor] = [:] + private var promptSubmitOpenAnchorStore = PromptSubmitOpenAnchorStore() private var indexes = NotificationIndexes() private init() { @@ -1243,7 +1269,7 @@ final class TerminalNotificationStore: ObservableObject { forTabId tabId: UUID, surfaceId: UUID? ) { - promptSubmitOpenAnchors[TabSurfaceKey(tabId: tabId, surfaceId: surfaceId)] = anchor + promptSubmitOpenAnchorStore.record(anchor, forTabId: tabId, surfaceId: surfaceId) } /// Returns the prompt-submit turn-start anchor for a workspace/surface notification target. @@ -1253,12 +1279,7 @@ final class TerminalNotificationStore: ObservableObject { /// - surfaceId: Terminal surface or panel identifier for the notification. /// - Returns: The most recent prompt-submit turn-start anchor for the target, if one was recorded. func promptSubmitOpenAnchor(forTabId tabId: UUID, surfaceId: UUID?) -> TerminalNotificationOpenAnchor? { - let target = TabSurfaceKey(tabId: tabId, surfaceId: surfaceId) - if let anchor = promptSubmitOpenAnchors[target] { - return anchor - } - guard surfaceId != nil else { return nil } - return promptSubmitOpenAnchors[TabSurfaceKey(tabId: tabId, surfaceId: nil)] + promptSubmitOpenAnchorStore.anchor(forTabId: tabId, surfaceId: surfaceId) } /// Removes prompt-submit anchors for a tab and the supplied surface aliases. @@ -1272,13 +1293,11 @@ final class TerminalNotificationStore: ObservableObject { surfaceIds: Set, removesNilFallback: Bool ) { - promptSubmitOpenAnchors = promptSubmitOpenAnchors.filter { entry in - guard entry.key.tabId == tabId else { return true } - guard let surfaceId = entry.key.surfaceId else { - return !removesNilFallback - } - return !surfaceIds.contains(surfaceId) - } + promptSubmitOpenAnchorStore.remove( + forTabId: tabId, + surfaceIds: surfaceIds, + removesNilFallback: removesNilFallback + ) } func clearLatestNotification(forTabId tabId: UUID) { @@ -1871,6 +1890,7 @@ final class TerminalNotificationStore: ObservableObject { let removedIds = notifications .filter { $0.tabId == tabId } .map { $0.id.uuidString } + promptSubmitOpenAnchorStore.removeAll(forTabId: tabId) var usedNotificationIds = Set(notifications.filter { $0.tabId != tabId }.map(\.id)) let restoredForTab = restoredNotifications .filter { $0.tabId == tabId } @@ -1928,10 +1948,12 @@ final class TerminalNotificationStore: ObservableObject { !focusedReadIndicatorByTabId.isEmpty || !manualUnreadWorkspaceIds.isEmpty || !panelDerivedUnreadWorkspaceIds.isEmpty || - !restoredUnreadWorkspaceIds.isEmpty else { return } + !restoredUnreadWorkspaceIds.isEmpty || + !promptSubmitOpenAnchorStore.isEmpty else { return } let tabIdsToClearPanelUnread = panelDerivedUnreadWorkspaceIds.union(notifications.map(\.tabId)) let ids = notifications.map { $0.id.uuidString } replaceNotificationsForClear([]) + promptSubmitOpenAnchorStore.removeAll() clearWorkspaceManualUnread() clearAllWorkspacePanelUnread(forTabIds: tabIdsToClearPanelUnread) clearPanelDerivedWorkspaceUnread() @@ -2020,6 +2042,11 @@ final class TerminalNotificationStore: ObservableObject { if didMoveNotification { notifications = updated } + promptSubmitOpenAnchorStore.rebindSurface( + fromTabId: sourceTabId, + toTabId: destinationTabId, + surfaceId: surfaceId + ) if focusedReadIndicatorByTabId[sourceTabId] == surfaceId { focusedReadIndicatorByTabId.removeValue(forKey: sourceTabId) @@ -2031,9 +2058,7 @@ final class TerminalNotificationStore: ObservableObject { func clearNotifications(forTabId tabId: UUID, discardQueuedNotifications: Bool = true) { if discardQueuedNotifications { TerminalMutationBus.shared.discardPendingNotifications(forTabId: tabId) } - promptSubmitOpenAnchors = promptSubmitOpenAnchors.filter { entry in - entry.key.tabId != tabId - } + promptSubmitOpenAnchorStore.removeAll(forTabId: tabId) let hadFocusedReadIndicator = focusedReadIndicatorByTabId[tabId] != nil var updated: [TerminalNotification] = [] updated.reserveCapacity(notifications.count) @@ -2099,18 +2124,7 @@ final class TerminalNotificationStore: ObservableObject { } content.sound = effects.sound ? NotificationSoundSettings.sound() : nil content.categoryIdentifier = Self.categoryIdentifier - content.userInfo = [ - "tabId": notification.tabId.uuidString, - "notificationId": notification.id.uuidString, - ] - if let surfaceId = notification.surfaceId { - content.userInfo["surfaceId"] = surfaceId.uuidString - } - if let clickAction = notification.clickAction { - for (key, value) in clickAction.userInfo { - content.userInfo[key] = value - } - } + content.userInfo = Self.userInfo(for: notification) let request = UNNotificationRequest( identifier: notification.id.uuidString, @@ -2142,6 +2156,31 @@ final class TerminalNotificationStore: ObservableObject { } } + /// Builds the system notification user-info payload for a terminal notification. + /// + /// - Parameter notification: Terminal notification being delivered to Notification Center. + /// - Returns: A user-info payload containing routing and reopen metadata. + static func userInfo(for notification: TerminalNotification) -> [AnyHashable: Any] { + var userInfo: [AnyHashable: Any] = [ + "tabId": notification.tabId.uuidString, + "notificationId": notification.id.uuidString, + ] + if let surfaceId = notification.surfaceId { + userInfo["surfaceId"] = surfaceId.uuidString + } + if let clickAction = notification.clickAction { + for (key, value) in clickAction.userInfo { + userInfo[key] = value + } + } + if let openAnchor = notification.openAnchor { + for (key, value) in openAnchor.userInfo { + userInfo[key] = value + } + } + return userInfo + } + private func playSuppressedNotificationFeedback( for notification: TerminalNotification, effects: TerminalNotificationPolicyEffects @@ -2445,7 +2484,7 @@ final class TerminalNotificationStore: ObservableObject { func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) { TerminalMutationBus.shared.discardPendingNotifications() self.notifications = notifications - promptSubmitOpenAnchors.removeAll() + promptSubmitOpenAnchorStore.removeAll() clearWorkspaceManualUnread() clearPanelDerivedWorkspaceUnread() clearWorkspaceRestoredUnread() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3409ea170e50..4414959ad0be 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -417,6 +417,7 @@ A5C0DE0000000000000000B4 /* ProjectPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B3 /* ProjectPanelView.swift */; }; A5C0DE0000000000000000BC /* ProjectSchemesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000BB /* ProjectSchemesTabView.swift */; }; A5C0DE0000000000000000B8 /* ProjectTargetsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0DE0000000000000000B7 /* ProjectTargetsTabView.swift */; }; + BEEF55280000000000000001 /* PromptSubmitOpenAnchorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF55280000000000000002 /* PromptSubmitOpenAnchorStore.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; A5001643 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */; }; B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */; }; @@ -1092,6 +1093,7 @@ A5C0DE0000000000000000B3 /* ProjectPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectPanelView.swift; sourceTree = ""; }; A5C0DE0000000000000000BB /* ProjectSchemesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectSchemesTabView.swift; sourceTree = ""; }; A5C0DE0000000000000000B7 /* ProjectTargetsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProjectTargetsTabView.swift; sourceTree = ""; }; + BEEF55280000000000000002 /* PromptSubmitOpenAnchorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSubmitOpenAnchorStore.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; A5001642 /* RemoteInteractiveShellBootstrapBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteInteractiveShellBootstrapBuilder.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C00002 /* RemoteLoopbackProxyAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteLoopbackProxyAlias.swift; sourceTree = ""; }; @@ -1737,6 +1739,7 @@ 2F0C05000000000000000001 /* MainWindowFocusController.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, + BEEF55280000000000000002 /* PromptSubmitOpenAnchorStore.swift */, A5001097 /* TerminalNotificationPolicy.swift */, F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */, A5A5A502A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift */, @@ -2668,6 +2671,7 @@ A5C0DE0000000000000000B4 /* ProjectPanelView.swift in Sources */, A5C0DE0000000000000000BC /* ProjectSchemesTabView.swift in Sources */, A5C0DE0000000000000000B8 /* ProjectTargetsTabView.swift in Sources */, + BEEF55280000000000000001 /* PromptSubmitOpenAnchorStore.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, A5001643 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, D0C0D0C0D0C0D0C0D0C00001 /* RemoteLoopbackProxyAlias.swift in Sources */, diff --git a/cmuxTests/WorkspacePromptSubmitTests.swift b/cmuxTests/WorkspacePromptSubmitTests.swift index 71960863f627..dee814a054b2 100644 --- a/cmuxTests/WorkspacePromptSubmitTests.swift +++ b/cmuxTests/WorkspacePromptSubmitTests.swift @@ -204,6 +204,31 @@ final class WorkspacePromptSubmitTests: XCTestCase { XCTAssertEqual(notification.openAnchor, anchor) } + /// Verifies delivered system notifications carry anchors even when store recording is disabled. + func testNotificationUserInfoPreservesOpenAnchorForUnrecordedDesktopDelivery() throws { + let workspaceId = UUID() + let surfaceId = UUID() + let anchor = TerminalNotificationOpenAnchor(scrollbarOffset: 128) + let notification = TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: surfaceId, + title: "Agent finished", + subtitle: "codex", + body: "Done", + createdAt: Date(), + isRead: false, + openAnchor: anchor + ) + + let userInfo = TerminalNotificationStore.userInfo(for: notification) + + XCTAssertEqual(userInfo["tabId"] as? String, workspaceId.uuidString) + XCTAssertEqual(userInfo["surfaceId"] as? String, surfaceId.uuidString) + XCTAssertEqual(userInfo["notificationId"] as? String, notification.id.uuidString) + XCTAssertEqual(TerminalNotificationOpenAnchor(userInfo: userInfo), anchor) + } + /// Verifies workspace notification clearing also removes prompt-submit anchors. func testClearNotificationsForTabIdRemovesPromptSubmitOpenAnchorsWithoutNotifications() { let store = TerminalNotificationStore.shared @@ -239,6 +264,25 @@ final class WorkspacePromptSubmitTests: XCTestCase { ) } + /// Verifies global notification clearing also removes prompt-submit anchors. + func testClearAllRemovesPromptSubmitOpenAnchorsWithoutNotifications() { + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([]) + defer { store.replaceNotificationsForTesting([]) } + + let workspaceId = UUID() + let surfaceId = UUID() + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 42), + forTabId: workspaceId, + surfaceId: surfaceId + ) + + store.clearAll(discardQueuedNotifications: false) + + XCTAssertNil(store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: surfaceId)) + } + /// Verifies surface notification clearing removes only the matching prompt-submit anchor. func testClearNotificationsForSurfaceRemovesOnlyMatchingPromptSubmitOpenAnchor() { let store = TerminalNotificationStore.shared @@ -272,6 +316,50 @@ final class WorkspacePromptSubmitTests: XCTestCase { ) } + /// Verifies session restore clears runtime anchors from the previous tab lifetime. + func testRestoreSessionNotificationsRemovesPromptSubmitOpenAnchorsForTab() { + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([]) + defer { store.replaceNotificationsForTesting([]) } + + let workspaceId = UUID() + let surfaceId = UUID() + store.recordPromptSubmitOpenAnchor( + TerminalNotificationOpenAnchor(scrollbarOffset: 42), + forTabId: workspaceId, + surfaceId: surfaceId + ) + + store.restoreSessionNotifications([], forTabId: workspaceId) + + XCTAssertNil(store.promptSubmitOpenAnchor(forTabId: workspaceId, surfaceId: surfaceId)) + } + + /// Verifies surface rebinds move prompt-submit anchors to the destination workspace. + func testRebindSurfaceNotificationsMovesPromptSubmitOpenAnchor() { + let store = TerminalNotificationStore.shared + store.replaceNotificationsForTesting([]) + defer { store.replaceNotificationsForTesting([]) } + + let sourceWorkspaceId = UUID() + let destinationWorkspaceId = UUID() + let surfaceId = UUID() + let anchor = TerminalNotificationOpenAnchor(scrollbarOffset: 42) + store.recordPromptSubmitOpenAnchor(anchor, forTabId: sourceWorkspaceId, surfaceId: surfaceId) + + store.rebindSurfaceNotifications( + fromTabId: sourceWorkspaceId, + toTabId: destinationWorkspaceId, + surfaceId: surfaceId + ) + + XCTAssertNil(store.promptSubmitOpenAnchor(forTabId: sourceWorkspaceId, surfaceId: surfaceId)) + XCTAssertEqual( + store.promptSubmitOpenAnchor(forTabId: destinationWorkspaceId, surfaceId: surfaceId), + anchor + ) + } + /// Verifies surface notification clearing removes panel, surface alias, and nil-fallback anchors. func testClearNotificationsForSurfaceRemovesPromptSubmitOpenAnchorAliases() { let store = TerminalNotificationStore.shared From d7a35a5dabeb7d0761c0569a8075653530c8d348 Mon Sep 17 00:00:00 2001 From: duroy Date: Tue, 9 Jun 2026 15:41:51 +0800 Subject: [PATCH 10/10] fix(settings): replace visibility namespace enum --- .../Navigation/SettingsSectionVisibilityCoordinateSpace.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityCoordinateSpace.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityCoordinateSpace.swift index 9d0a417bcb3e..29de05cea6ec 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityCoordinateSpace.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionVisibilityCoordinateSpace.swift @@ -1,3 +1,5 @@ -enum SettingsSectionVisibilityCoordinateSpace { +struct SettingsSectionVisibilityCoordinateSpace { static let name = "SettingsSectionVisibilityCoordinateSpace" + + private init() {} }