Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7a24c0c
test: reproduce stale notification preview after terminal activity
austinywang Aug 25, 2026
648cb54
fix: retire stale sidebar notification previews on activity
austinywang Aug 26, 2026
4477237
Merge branch 'main' into issue-8359-stale-sidebar-notification-after-…
austinywang Sep 1, 2026
666c009
Merge remote-tracking branch 'origin/main' into issue-8359-stale-side…
austinywang Sep 1, 2026
0939628
Merge remote-tracking branch 'origin/main' into issue-8359-stale-side…
austinywang Sep 1, 2026
8603e43
test: require batched sidebar preview retirement
austinywang Sep 1, 2026
8491d67
fix: batch sidebar notification preview retirement
austinywang Sep 1, 2026
9d6c64f
Merge remote-tracking branch 'origin/main' into issue-8359-stale-side…
austinywang Sep 1, 2026
346fe17
Merge remote-tracking branch 'origin/main' into issue-8359-stale-side…
austinywang Sep 1, 2026
a175ef9
Merge remote-tracking branch 'origin/main' into issue-8359-stale-side…
austinywang Sep 1, 2026
9d2330e
Merge remote-tracking branch 'origin/main' into issue-8359-stale-side…
austinywang Sep 1, 2026
3f18e86
test: restore notification tombstone state
austinywang Sep 1, 2026
a46a34e
Merge remote-tracking branch 'refs/remotes/origin/main' into issue-83…
austinywang Sep 1, 2026
2103421
perf: use set for dismissed notification membership
austinywang Sep 1, 2026
667c3ce
Merge remote-tracking branch 'refs/remotes/origin/main' into issue-83…
austinywang Sep 1, 2026
4e9cf30
fix: cancel pending notification previews during batch removal
austinywang Sep 1, 2026
5c2a09c
Merge remote-tracking branch 'refs/remotes/origin/main' into issue-83…
austinywang Sep 1, 2026
5aa953c
Merge remote-tracking branch 'refs/remotes/origin/main' into issue-83…
austinywang Sep 1, 2026
1fce13e
fix: avoid duplicate pending notification cleanup
austinywang Sep 1, 2026
100e1d4
Merge commit 'eaa899cb20bd411019744fbd2bdedeb397f3070b' into issue-83…
austinywang Sep 1, 2026
000f6a8
Merge commit '6044a8b3f43152d2e6fc17f771fd4b277b393118' into issue-83…
austinywang Sep 1, 2026
a687f40
Merge commit '594eb0461e0ae4d57a99180e19097cea5e5091e0' into issue-83…
austinywang Sep 1, 2026
b8e4c30
fix: gate lifecycle preview cleanup on indexed state
austinywang Sep 1, 2026
f2d3364
Merge commit '544c0e0ff4f3aebb1a34eb8503c7ccc1f33a2de8' into issue-83…
austinywang Sep 1, 2026
9103df9
Merge commit '2422b69c7c3555bd1efc5b37ffb32a7c619dc282' into issue-83…
austinywang Sep 1, 2026
ff07432
Merge commit '3d309ec1a5fd8620a8fb00f688783957ebedc035' into issue-83…
austinywang Sep 1, 2026
ef977d7
Merge commit '9e2dd50957936153ca0da61d2f079937674f9375' into issue-83…
austinywang Sep 1, 2026
a39224a
Merge remote-tracking branch 'origin/main' into issue-8359-stale-side…
austinywang Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,20 @@ public protocol NotificationDismissalHosting: AnyObject {
/// Whether a visible notification indicator exists for the workspace
/// (or surface).
func storeHasVisibleNotificationIndicator(workspaceId: UUID, surfaceId: UUID?) -> Bool
/// Whether the workspace's current sidebar preview is a read notification
/// that can be retired by a new activity boundary. This is intentionally
/// separate from ``storeHasDismissibleState`` because read notifications
/// remain in the active store for the sidebar until activity retires them.
func storeHasSidebarNotificationPreview(workspaceId: UUID) -> Bool

// MARK: Mutations

/// Marks the workspace's (or surface's) notifications read.
func storeMarkRead(workspaceId: UUID, surfaceId: UUID?)
/// Retires active sidebar notification previews for a workspace while
/// leaving the chronological notification feed intact.
@discardableResult
func storeClearSidebarNotificationPreviews(workspaceId: UUID) -> Bool
/// Clears the workspace-level manual unread indicator; returns whether
/// anything was cleared.
@discardableResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,35 @@ public final class NotificationDismissalModel: NotificationDismissing {

@discardableResult
public func dismissNotificationOnTerminalInteraction(workspaceId: UUID, surfaceId: UUID?) -> Bool {
dismissNotification(workspaceId: workspaceId, surfaceId: surfaceId, context: .terminalInteraction)
// Terminal input is an activity boundary even when the notification
// was already read. The normal dismissal path intentionally keeps a
// read notification as the sidebar preview, so retire that live
// preview separately while retaining the chronological feed record.
let didDismiss = dismissNotification(
workspaceId: workspaceId,
surfaceId: surfaceId,
context: .terminalInteraction
)
guard let host, host.hasNotificationStore,
host.storeHasSidebarNotificationPreview(workspaceId: workspaceId) else {
return didDismiss
}
// `dismissNotification` already proved selection when it changed an
// indicator. A read-only preview has no dismissible aggregate state,
// so perform the same selection gate only for that case. Keeping the
// aggregate fast path intact matters because this callback runs on
// every explicit terminal keystroke.
if !didDismiss,
!host.isNotificationTargetSelected(
workspaceId: workspaceId,
surfaceId: surfaceId
) {
return false
}
let didClearPreview = host.storeClearSidebarNotificationPreviews(
workspaceId: workspaceId
)
return didDismiss || didClearPreview
}

@discardableResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ private final class FakeHost: NotificationDismissalHosting {
var visibleIndicatorSurfaces: Set<UUID> = []
var pendingNotificationSurfaces: Set<UUID> = []
var workspacesWithPendingNotifications: Set<UUID> = []
var sidebarPreviewAvailable = false
var hasDismissibleState = true
var hasDismissiblePanelState = false
var selectionLookupCount = 0
Expand Down Expand Up @@ -96,10 +97,21 @@ private final class FakeHost: NotificationDismissalHosting {
return visibleIndicatorSurfaces.contains(surfaceId)
}

func storeHasSidebarNotificationPreview(workspaceId: UUID) -> Bool {
sidebarPreviewAvailable
}

func storeMarkRead(workspaceId: UUID, surfaceId: UUID?) {
log.append("markRead:\(short(surfaceId))")
}

func storeClearSidebarNotificationPreviews(workspaceId: UUID) -> Bool {
guard sidebarPreviewAvailable else { return false }
log.append("storeClearSidebarNotificationPreviews")
sidebarPreviewAvailable = false
return true
}

func storeClearManualUnread(workspaceId: UUID) -> Bool {
log.append("storeClearManualUnread")
return manualWorkspaceUnread.contains(workspaceId)
Expand Down Expand Up @@ -323,6 +335,31 @@ struct NotificationDismissalModelTests {
#expect(host.log.isEmpty)
}

@Test func terminalInteractionRetiresReadSidebarPreviewAfterSelectionGate() {
let (model, host, workspaceId, panelId) = makeModel()
host.hasDismissibleState = false
host.sidebarPreviewAvailable = true

#expect(model.dismissNotificationOnTerminalInteraction(workspaceId: workspaceId, surfaceId: panelId))
#expect(host.selectionLookupCount == 1)
#expect(host.detailedLookupCount == 0)
#expect(!host.sidebarPreviewAvailable)
#expect(host.log == ["storeClearSidebarNotificationPreviews"])
}

@Test func terminalInteractionDoesNotRetirePreviewForUnselectedWorkspace() {
let (model, host, workspaceId, panelId) = makeModel()
host.hasDismissibleState = false
host.sidebarPreviewAvailable = true
host.selectedWorkspaceId = UUID()

#expect(!model.dismissNotificationOnTerminalInteraction(workspaceId: workspaceId, surfaceId: panelId))
#expect(host.selectionLookupCount == 1)
#expect(host.detailedLookupCount == 0)
#expect(host.sidebarPreviewAvailable)
#expect(host.log.isEmpty)
}

@Test func visualOnlyRestoredPanelStateBypassesEmptyStoreAggregate() {
let (model, host, workspaceId, panelId) = makeModel()
host.hasDismissibleState = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ private final class StubCallingCenter: UserNotificationCenterCalling, @unchecked
var startedAdds = 0
var finishedAdds = 0
var startedRemovals = 0
var deliveredRemovalIdentifiers: [[String]] = []
var pendingRemovalIdentifiers: [[String]] = []
}

// Safety: the lock guards simple counters written from the service's
Expand Down Expand Up @@ -54,6 +56,14 @@ private final class StubCallingCenter: UserNotificationCenterCalling, @unchecked
state.withLock { $0.startedRemovals }
}

var deliveredRemovalIdentifiers: [[String]] {
state.withLock { $0.deliveredRemovalIdentifiers }
}

var pendingRemovalIdentifiers: [[String]] {
state.withLock { $0.pendingRemovalIdentifiers }
}

func setNotificationCategories(_ categories: Set<UNNotificationCategory>) {}

func getNotificationCategories(
Expand Down Expand Up @@ -90,11 +100,17 @@ private final class StubCallingCenter: UserNotificationCenterCalling, @unchecked
}

func removeDeliveredNotifications(withIdentifiers identifiers: [String]) {
state.withLock { $0.startedRemovals += 1 }
state.withLock {
$0.startedRemovals += 1
$0.deliveredRemovalIdentifiers.append(identifiers)
}
}

func removePendingNotificationRequests(withIdentifiers identifiers: [String]) {
state.withLock { $0.startedRemovals += 1 }
state.withLock {
$0.startedRemovals += 1
$0.pendingRemovalIdentifiers.append(identifiers)
}
}
}

Expand Down Expand Up @@ -360,5 +376,7 @@ struct UserNotificationCenterServiceTests {
#expect(Self.isSuccess(await service.removeDeliveredNotifications(withIdentifiers: ["a"])))
#expect(Self.isSuccess(await service.removePendingNotificationRequests(withIdentifiers: ["b"])))
#expect(center.startedRemovals == 2)
#expect(center.deliveredRemovalIdentifiers == [["a"]])
#expect(center.pendingRemovalIdentifiers == [["b"]])
}
}
37 changes: 35 additions & 2 deletions Sources/ControlSidebarPanelOwner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,46 @@ enum ControlSidebarPanelOwner {
panelId: UUID?,
lifecycle: AgentHibernationLifecycleState
) {
let didResumeFromAnotherLifecycle: Bool
switch self {
case .workspace(let workspace):
workspace.setAgentLifecycle(key: key, panelId: panelId, lifecycle: lifecycle)
guard let targetPanelId = panelId ?? workspace.focusedPanelId,
workspace.panels[targetPanelId] != nil else {
return
}
let previousLifecycle = workspace.agentLifecycleStatesByPanelId[targetPanelId]?[key]
workspace.setAgentLifecycle(key: key, panelId: targetPanelId, lifecycle: lifecycle)
didResumeFromAnotherLifecycle = lifecycle == .running &&
previousLifecycle != .running
case .dock(let dock):
guard let panelId else { return }
guard let panelId, dock.panels[panelId] != nil else { return }
let previousLifecycle = dock.agentRuntimeByPanelId[panelId]?.agentLifecycleStates[key]
dock.setAgentLifecycle(key: key, panelId: panelId, lifecycle: lifecycle)
didResumeFromAnotherLifecycle = lifecycle == .running &&
previousLifecycle != .running
}
guard didResumeFromAnotherLifecycle,
!AgentHibernationLifecycleStatusKeys.isManualKey(key) else {
return
}
// A lifecycle transition is activity even when the provider did not
// send terminal input. Retire only the live sidebar previews; the
// notification feed remains the historical record.
let notificationStore: TerminalNotificationStore?
switch self {
case .workspace:
notificationStore = AppDelegate.shared?.notificationStore
case .dock(let dock):
notificationStore = dock.resolvedNotificationStore()
}
// The latest-notification index is an O(1) no-op gate for the common
// resume-without-a-read-preview path; only scan active notifications
// when the current sidebar preview is actually retireable.
guard let notificationStore,
notificationStore.hasSidebarNotificationPreview(forTabId: id) else {
return
}
notificationStore.clearSidebarNotificationPreviews(forTabId: id)
}

@discardableResult
Expand Down
9 changes: 9 additions & 0 deletions Sources/TabManager+NotificationDismissalHosting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,19 @@ extension TabManager: NotificationDismissalHosting {
.hasVisibleNotificationIndicator(forTabId: workspaceId, surfaceId: surfaceId) ?? false
}

func storeHasSidebarNotificationPreview(workspaceId: UUID) -> Bool {
AppDelegate.shared?.notificationStore?.hasSidebarNotificationPreview(forTabId: workspaceId) ?? false
}

func storeMarkRead(workspaceId: UUID, surfaceId: UUID?) {
AppDelegate.shared?.notificationStore?.markRead(forTabId: workspaceId, surfaceId: surfaceId)
}

@discardableResult
func storeClearSidebarNotificationPreviews(workspaceId: UUID) -> Bool {
AppDelegate.shared?.notificationStore?.clearSidebarNotificationPreviews(forTabId: workspaceId) ?? false
}

@discardableResult
func storeClearManualUnread(workspaceId: UUID) -> Bool {
AppDelegate.shared?.notificationStore?.clearManualUnread(forTabId: workspaceId) ?? false
Expand Down
87 changes: 70 additions & 17 deletions Sources/TerminalNotificationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,8 @@ final class TerminalNotificationStore: ObservableObject {
emitNotificationsDismissed(ids: ids)
return
}
let extra = drainedSuperseded.filter { !ids.contains($0) }
let idSet = Set(ids)
let extra = drainedSuperseded.filter { !idSet.contains($0) }
emitNotificationsDismissed(ids: ids + extra)
}

Expand Down Expand Up @@ -1160,6 +1161,14 @@ final class TerminalNotificationStore: ObservableObject {
indexes.latestByTabId[tabId]
}

/// Whether the current sidebar preview is a read notification that can be
/// retired by a subsequent activity boundary. An unread latest entry is
/// deliberately left alone so an unrelated outstanding alert remains
/// visible while another surface is active.
func hasSidebarNotificationPreview(forTabId tabId: UUID) -> Bool {
indexes.latestByTabId[tabId]?.isRead == true
}

func notifications(forTabId tabId: UUID, surfaceId: UUID?) -> [TerminalNotification] {
notifications.filter { $0.matches(tabId: tabId, surfaceId: surfaceId) }
}
Expand All @@ -1169,6 +1178,15 @@ final class TerminalNotificationStore: ObservableObject {
remove(id: latestNotification.id)
}

/// Retires read notification previews for a workspace after new
/// terminal/agent activity. Unread entries remain available to represent
/// an outstanding alert, while ``notificationFeedHistory`` remains the
/// durable chronological record for the entries that are retired.
@discardableResult
func clearSidebarNotificationPreviews(forTabId tabId: UUID) -> Bool {
removeNotifications { $0.tabId == tabId && $0.isRead }
}

func focusedReadIndicatorSurfaceId(forTabId tabId: UUID) -> UUID? {
focusedReadIndicatorByTabId[tabId]
}
Expand Down Expand Up @@ -2116,26 +2134,61 @@ final class TerminalNotificationStore: ObservableObject {
}

func remove(id: UUID) {
var updated = notifications
let removed = updated.first(where: { $0.id == id })
let originalCount = updated.count
updated.removeAll { $0.id == id }
guard updated.count != originalCount else { return }
_ = removeNotifications { $0.id == id }
}

/// Removes several active notifications in one mutation. The sidebar
/// activity path can retire many read previews at once; filtering and
/// publishing once keeps that path O(n + r) instead of repeating the full
/// active-array/index work for every removed entry.
@discardableResult
private func removeNotifications(
where shouldRemove: (TerminalNotification) -> Bool
) -> Bool {
var updated: [TerminalNotification] = []
updated.reserveCapacity(notifications.count)
var removedNotifications: [TerminalNotification] = []
for notification in notifications {
if shouldRemove(notification) {
removedNotifications.append(notification)
} else {
updated.append(notification)
}
}
guard !removedNotifications.isEmpty else { return false }

notifications = updated
notificationFeedHistory.markRead(ids: [id])
if let removed {
clearFocusedReadIndicator(forTabId: removed.tabId, surfaceId: removed.surfaceId)
let removedIDs = removedNotifications.map(\.id)
notificationFeedHistory.markRead(ids: Set(removedIDs))

var nextFocusedReadIndicators = focusedReadIndicatorByTabId
for notification in removedNotifications {
guard let existingSurfaceId = nextFocusedReadIndicators[notification.tabId],
notification.surfaceId == nil || existingSurfaceId == notification.surfaceId else {
continue
}
nextFocusedReadIndicators.removeValue(forKey: notification.tabId)
}
removeNotificationRequestsAndReleaseSoundReferences(withIdentifiers: [id.uuidString])
let supersededDrained = removed.map { removedNotification in
supersededPhoneDismissBuffer.flush(
if nextFocusedReadIndicators != focusedReadIndicatorByTabId {
focusedReadIndicatorByTabId = nextFocusedReadIndicators
}

let removedIDStrings = removedIDs.map(\.uuidString)
removeNotificationRequestsAndReleaseSoundReferences(withIdentifiers: removedIDStrings)
var drainedSuperseded: [String] = []
for notification in removedNotifications {
drainedSuperseded.append(contentsOf: supersededPhoneDismissBuffer.flush(
forKey: SupersededPhoneDismissBuffer.key(
tabId: removedNotification.tabId,
surfaceId: removedNotification.surfaceId
tabId: notification.tabId,
surfaceId: notification.surfaceId
)
)
} ?? []
emitNotificationsDismissed(ids: [id.uuidString], drainedSuperseded: supersededDrained)
))
}
emitNotificationsDismissed(
ids: removedIDStrings,
drainedSuperseded: drainedSuperseded
)
return true
}

func clearNotifications(forTabId tabId: UUID, correlationKey: String) {
Expand Down
Loading