Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
f868339
cmux NEXT: a tab an agent needs you in shows the unread dot, ring and…
teamleaderleo Oct 22, 2026
a874361
SidebarMapping: keep newestUnread's doc comment on newestUnread
teamleaderleo Oct 22, 2026
093e2f8
Attention ring: going into a remote marked tab hides its ring too
teamleaderleo Oct 23, 2026
3a5f9b9
Attention ring: resolve a click or key on the window's own machine; r…
teamleaderleo Oct 23, 2026
d5497ac
Attention ring: typing in another machine's terminal clears no worksp…
teamleaderleo Oct 23, 2026
1c62421
TabAttentionTests: the observation flag is nonisolated (onChange is a…
teamleaderleo Oct 23, 2026
e0d66c9
Test remote attention rings through a delayed strip click
teamleaderleo Oct 27, 2026
202baf9
Keep delayed strip clicks on their originating machine
teamleaderleo Oct 27, 2026
3da07d0
Pass the strip fixture readiness condition explicitly
teamleaderleo Oct 27, 2026
178e75a
Test attention ownership through workspace replacement and menu reads
teamleaderleo Oct 27, 2026
556886a
Bind attention reads to their owner and show clears immediately
teamleaderleo Oct 27, 2026
9774965
Guard attention clears and ring generations
teamleaderleo Oct 28, 2026
ae6dd36
Make attention clears conditional and monotonic
teamleaderleo Oct 28, 2026
62ce133
Document revision guarded attention clears
teamleaderleo Oct 28, 2026
02fec87
Persist attention clear revisions across remarks
teamleaderleo Oct 28, 2026
f681d4f
Fix decimal encoding for guarded attention clears
teamleaderleo Oct 28, 2026
3436f0f
fix: import wire decimal parser
teamleaderleo Oct 28, 2026
fa03a2b
fix: preflight attention clear capability
teamleaderleo Oct 28, 2026
f5bd46b
fix: read nullable attention revision
teamleaderleo Oct 28, 2026
91056b0
fix: preserve explicit attention targets
teamleaderleo Oct 28, 2026
1c8846f
style: format tab update SDK
teamleaderleo Oct 29, 2026
147e058
fix: scope remote highlight dismissal and validate revisions
teamleaderleo Oct 29, 2026
fd9f721
test: read SQLite timestamps as signed integers
teamleaderleo Oct 29, 2026
cf2d2f7
fix: scope highlight dismissal to its machine and pinned owner
teamleaderleo Oct 29, 2026
7249352
fix: serialize machine-scoped highlight debug state
teamleaderleo Oct 29, 2026
1961807
fix: provide scheduler in attention test fixtures
teamleaderleo Oct 29, 2026
d841668
fix: reject malformed guarded attention timestamps
teamleaderleo Oct 29, 2026
db8e929
fix: keep attention input ownership lookup indexed
teamleaderleo Oct 29, 2026
fc1fb49
test: import attention fixture scheduler module
teamleaderleo Oct 29, 2026
50284a5
fix: guard shared attention interactions by owner request identity
teamleaderleo Oct 29, 2026
c27c81c
Merge current base and use landed attention identity protocol
teamleaderleo Oct 29, 2026
f96e10c
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/attn-2…
teamleaderleo Oct 30, 2026
23ffe95
fix(attention): an older daemon's mark hides its ring on entry and Di…
teamleaderleo Oct 30, 2026
44d1031
fix(attention): a remote workspace or tab menu runs every targeted ro…
teamleaderleo Oct 30, 2026
a9a4b8b
fix(attention): a remote workspace menu's rename field renames it on …
teamleaderleo Oct 30, 2026
ca12a50
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/attn-2…
teamleaderleo Oct 30, 2026
06e3510
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/attn-2…
teamleaderleo Oct 30, 2026
8c0b5f5
fix(attention): a remote tab's menu rows resolve their pane on the ta…
teamleaderleo Oct 30, 2026
3e011c0
fix(attention): name the pane in ActionScope's pane lookup closure (c…
teamleaderleo Oct 30, 2026
ad63a5f
fix(attention): Move Tab keeps the remote tab model it was given
teamleaderleo Oct 30, 2026
b9ff9e9
Revert "fix(attention): Move Tab keeps the remote tab model it was gi…
teamleaderleo Oct 30, 2026
5279e61
Revert "fix(attention): name the pane in ActionScope's pane lookup cl…
teamleaderleo Oct 30, 2026
296589b
Revert "fix(attention): a remote tab's menu rows resolve their pane o…
teamleaderleo Oct 30, 2026
8ae6536
Revert "fix(attention): a remote workspace menu's rename field rename…
teamleaderleo Oct 30, 2026
cb954ef
Revert "fix(attention): a remote workspace or tab menu runs every tar…
teamleaderleo Oct 30, 2026
5825e87
fix(attention): a machine-scoped menu row validates against its machine
teamleaderleo Oct 30, 2026
8a0b088
fix(attention): opening a marked tab hides its ring
teamleaderleo Oct 30, 2026
dcba7f0
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/attn-2…
teamleaderleo Oct 30, 2026
9638e53
refactor(attention): TabInteractionTarget lives in CmuxNextDaemon (ap…
teamleaderleo Oct 30, 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 @@ -48,6 +48,8 @@ public nonisolated enum ActionOrigin: String, Sendable, Hashable, CaseIterable {
/// CLI `--target`, or nil for "the focused one") and the collected arguments.
public nonisolated struct ActionInvocation: Sendable, Hashable {
public var target: ActionTargetRef?
/// Namespace of a native menu's target. It identifies its owner, never grants authority.
public var targetMachineID: String?
public var arguments: [String: ActionValue]
/// In-app runs are the user's; the control socket sets its caller's.
public var origin: ActionOrigin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ extension ActionRegistry {
}

extension ActionRegistry {
/// Binds one menu action to the machine that supplied its native target. Other items keep
/// their ordinary invocation; no synthetic argument leaks into their handlers.
public func scopeMenuAction(_ menu: NSMenu, action: ActionID, toMachine machineID: String) {
for item in menu.items {
if let payload = item.representedObject as? ActionMenuPayload, payload.id == action {
payload.targetMachineID = machineID
if let title = self.action(for: action)?.targetTitle?(payload.invocation(in: self)) { item.title = title }
}
if let submenu = item.submenu { scopeMenuAction(submenu, action: action, toMachine: machineID) }
}
}

/// The action, target and arguments a generated menu item runs (nil
/// for items the registry did not make). Callers that retitle a row
/// (Search Google for "…") and tests read it.
Expand All @@ -39,6 +51,7 @@ extension ActionRegistry {
final class ActionMenuPayload: NSObject {
let id: ActionID
let target: ActionTargetRef?
var targetMachineID: String?
let arguments: [String: ActionValue]
/// Focus facts the right-clicked object stands in for (`ActionRegistry.impliedContext(for:)`):
/// the item is enabled and runs with them added to the window's context, the same facts
Expand All @@ -55,6 +68,7 @@ final class ActionMenuPayload: NSObject {
/// The run of this item in `registry`'s context, plus the implied facts.
func invocation(in registry: ActionRegistry, withArguments: Bool = true) -> ActionInvocation {
var invocation = ActionInvocation(target: target, arguments: withArguments ? arguments : [:])
invocation.targetMachineID = targetMachineID
if !implied.isEmpty { invocation.keyContext = registry.context.union(implied) }
return invocation
}
Expand Down Expand Up @@ -84,7 +98,9 @@ final class ActionMenuTarget: NSObject, NSMenuItemValidation {
if menuItem.isHidden { return false }
// A standalone key window claims the run: its close items stay
// enabled (they close it), actions on main window content are off.
let invocation = ActionInvocation(target: payload.target, arguments: payload.arguments)
var invocation = ActionInvocation(target: payload.target, arguments: payload.arguments)
// A row scoped to its target's machine (``scopeMenuAction``) validates against that machine, as it runs.
invocation.targetMachineID = payload.targetMachineID
// A main menu toggle reads as the change it makes for the focused target (Pin
// Workspace or Unpin Workspace, PINNED-ITEMS-END-TO-END); no target title, the
// catalog's. Context menu items (an ActionMenuPayload) are decorated when built.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ enum ActionRouting {
let routed = daemon(for: invocation, daemons: services.machines.daemons) { windowID in
windows.first { $0.state.id == windowID }.flatMap { services.machines.daemon(machine: $0.state.machineID) }
}
// A native context menu carries an explicit machine owner. If that
// machine disappeared while the menu was open, refuse the action;
// falling back to the active daemon could mutate a same-ID tab here.
if invocation.targetMachineID != nil, routed == nil {
services.registry.refuseNotFound("The target machine is no longer connected.")
return
}
let previous = services.routedDaemon
services.routedDaemon = routed ?? previous
body()
Expand All @@ -30,6 +37,7 @@ enum ActionRouting {
/// object's". `windowMachine` maps a window id to its machine's daemon.
static func daemon(for invocation: ActionInvocation, daemons: [DaemonService],
windowMachine: (String) -> DaemonService?) -> DaemonService? {
if let machine = invocation.targetMachineID { return daemons.first { $0.machineID == machine } }
let arguments = ["workspace", "tab", "pane", "group"].compactMap { invocation[$0]?.targetValue }
guard let target = invocation.target ?? arguments.first else { return nil }
switch target.kind {
Expand Down
4 changes: 4 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextApp/ActionScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ struct ActionScope {

var workspace: WorkspaceModel? {
if let target = invocation.target ?? invocation["workspace"]?.targetValue, target.kind == .workspace {
if let machine = invocation.targetMachineID {
return services.machines.daemon(machine: machine)?.store.workspaces.first { $0.id == target.id }
?? notFound(RefusalStrings.noWorkspace(target.id))
}
return services.workspace(id: target.id) ?? notFound(RefusalStrings.noWorkspace(target.id))
}
return window?.state.workspaceID.flatMap(services.workspace(id:))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ extension AppActionContext {
/// The targeted daemon tab, found in any workspace (shown or not).
func daemonTab(_ invocation: ActionInvocation) -> (tab: TabModel, pane: PaneModel)? {
if let target = explicitTarget(invocation, kinds: [.tab]) {
if let machine = invocation.targetMachineID {
guard let store = services.machines.daemon(machine: machine)?.store,
let tab = store.tab(idOrResource: target.id), let pane = store.pane(holding: tab) else {
return notFound(RefusalStrings.noTab(target.id))
}
return (tab, pane)
}
return services.locateTab(target.id) ?? notFound(RefusalStrings.noTab(target.id))
}
guard let (pane, id) = tab(invocation) else { return nil }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ enum DebugNotifications {
"log": .array(center.log.map(JSONValue.string)),
"feed_log": .array((center.feedDriver?.log ?? []).map(JSONValue.string)),
"dock_badge": center.dockBadge.label.map(JSONValue.string) ?? .null,
"dismissed_highlights": .object(center.dismissedHighlights.mapValues { .number(Double($0)) }),
"dismissed_highlights": .object(Dictionary(uniqueKeysWithValues: center.dismissedHighlights.map { key, value in
("\(key.machine):\(key.session ?? ""):\(key.id)", .number(Double(value)))
})),
"highlight_look": .string(AttentionHighlightLook.tunable.value.rawValue),
"preferences": [
"dismissal": .string(prefs.dismissal.rawValue), "desktop": .string(prefs.desktop.rawValue),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ extension NotificationCenterService {
shownTab: @MainActor (PaneModel) -> String? = { _ in nil }) -> [LayoutPaneID: AttentionMark] {
let attention = DesignSettings.shared.attention
guard attention.style != .none, !preferences.mutedWorkspaces.contains(workspace.id) else { return [:] }
let sameSession = dismissedHighlightSession == services?.daemon.identity?.session
let dismissed = sameSession ? dismissedHighlights[workspace.id] ?? 0 : 0
let key = highlightKey(for: workspace)
let dismissed = dismissedHighlights[key] ?? 0
var marks: [LayoutPaneID: AttentionMark] = [:]
for screen in workspace.screens {
for pane in screen.panes {
Expand All @@ -153,7 +153,7 @@ extension NotificationCenterService {
let agentChat = shown.agentSession != nil
guard attention.rings(agentChat: agentChat) else { continue }
// An agent chat whose turn ended rings until the person goes into it.
if let finished = agentFinished[shown.id] {
if key.machine == MachineRegistry.localID, let finished = agentFinished[shown.id] {
marks[LayoutPaneID(pane.id)] = AttentionMark(color: preferences.sources[.agent]?.color, generation: finished,
agentChat: agentChat, quiet: quietAgentFinishes.contains(shown.id))
continue
Expand All @@ -178,12 +178,20 @@ extension NotificationCenterService {
let generation = tab.notification?.notification.rawValue ?? 1
if generation > dismissed { return generation }
}
guard tab.markedUnread, tab.markedUnreadGeneration > (dismissedMarks[tab.id] ?? 0) else { return nil }
return Self.markRingBase | tab.markedUnreadGeneration
if tab.markedUnread, tab.markedUnreadGeneration > (dismissedMarks[highlightKey(for: tab)] ?? 0) {
return Self.markRingBase | tab.markedUnreadGeneration
}
// An agent's "needs you here" mark (tab-attention-v1) rings until Dismiss Highlight hides
// that request; a different owner-issued request rings again.
guard tab.attention != nil,
tab.attentionRingHiddenThrough.map({ $0 != tab.attentionGeneration }) ?? true else { return nil }
return Self.markRingBase | Self.attentionRingBase | (tab.attentionGeneration & ~(Self.markRingBase | Self.attentionRingBase))
}

/// The bit a marked tab's ring generation carries, apart from notification ids.
static let markRingBase: UInt64 = 1 << 63
/// The bit an agent's attention ring carries, apart from manual marks.
static let attentionRingBase: UInt64 = 1 << 62

/// Of the panes in `removed` (their mark just went away), those read because
/// the person went into them: their ring blinks out. Forgets the entry
Expand Down Expand Up @@ -211,24 +219,38 @@ extension NotificationCenterService {
!attentionMarks(for: workspace).isEmpty
}

private func highlightKey(for workspace: WorkspaceModel) -> HighlightKey {
let daemon = services?.machines.daemons.first { $0.store.workspace(id: workspace.id) === workspace }
return HighlightKey(machine: daemon?.machineID ?? MachineRegistry.localID,
session: daemon?.identity?.session, id: workspace.id)
}

private func highlightKey(for tab: TabModel) -> HighlightKey {
let daemon = services?.machines.daemon(forTab: tab)
return HighlightKey(machine: daemon?.machineID ?? MachineRegistry.localID,
session: daemon?.identity?.session, id: tab.id)
}

/// Dismiss Highlight (cx-epgo): hides `workspace`'s attention rings
/// until a newer notification arrives or a tab is marked unread again.
/// The tabs stay unread (the tab dot, the row badge and the Dock badge
/// keep them).
func dismissHighlight(_ workspace: WorkspaceModel) {
let tabs = workspace.screens.flatMap(\.panes).flatMap(\.tabs)
let newest = tabs.filter(\.hasUnread).map { $0.notification?.notification.rawValue ?? 1 }.max() ?? 0
let session = services?.daemon.identity?.session
if session != dismissedHighlightSession {
dismissedHighlights = [:]
dismissedHighlightSession = session
let key = highlightKey(for: workspace)
// A restarted daemon has new notification IDs; discard only that machine's old session.
dismissedHighlights = dismissedHighlights.filter { $0.key.machine != key.machine || $0.key.session == key.session }
dismissedMarks = dismissedMarks.filter { $0.key.machine != key.machine || $0.key.session == key.session }
if key.machine == MachineRegistry.localID {
for tab in tabs where agentFinished[tab.id] != nil { agentFinished[tab.id] = nil; quietAgentFinishes.remove(tab.id) }
}
for tab in tabs where agentFinished[tab.id] != nil { agentFinished[tab.id] = nil; quietAgentFinishes.remove(tab.id) }
for tab in tabs where tab.markedUnread && dismissedMarks[tab.id] != tab.markedUnreadGeneration {
dismissedMarks[tab.id] = tab.markedUnreadGeneration
for tab in tabs where tab.markedUnread {
dismissedMarks[highlightKey(for: tab)] = tab.markedUnreadGeneration
}
guard newest > (dismissedHighlights[workspace.id] ?? 0) else { return }
dismissedHighlights[workspace.id] = newest
for tab in tabs where tab.attention != nil { tab.attentionRingHiddenThrough = tab.attentionGeneration }
guard newest > (dismissedHighlights[key] ?? 0) else { return }
dismissedHighlights[key] = newest
note("highlight dismissed workspace=\(workspace.id) through=\(newest)")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ extension NotificationCenterService {
/// The pane's selected tab once the press was dispatched (the one it selected), taken then:
/// by the mouse-up a close under the press has already selected a neighbor.
let tab: String
/// The exact tab and store pressed; its window can show another workspace before release.
let target: TabInteractionTarget?
/// Where it went down (screen coordinates; a torn-off tab's mouse-up comes in another window).
let screenLocation: NSPoint
/// The pointer went past the tab drag distance at some point: a drag, even if it came back.
Expand All @@ -25,8 +27,8 @@ extension NotificationCenterService {
/// A strip press began on `tab` at `screenLocation`. A press whose mouse-up a tracking loop took
/// before this ran (the titlebar row's window drag, `WindowTitlebar`) is already over: it
/// resolves now, from where the pointer is. The app's own input has no hardware button to ask.
func beginStripPress(tab: String, at screenLocation: NSPoint, synthetic: Bool) {
stripPress = StripPress(tab: tab, screenLocation: screenLocation)
func beginStripPress(tab: String, target: TabInteractionTarget?, at screenLocation: NSPoint, synthetic: Bool) {
stripPress = StripPress(tab: tab, target: target, screenLocation: screenLocation)
viewHolds.insert(tab)
if !synthetic, !leftButtonIsDown() { noteMouseUp(in: nil, at: pointerLocation()) }
}
Expand All @@ -51,7 +53,7 @@ extension NotificationCenterService {
if press.dragged || Self.beyondDragDistance(up, press.screenLocation) { return }
// A click: the hold ends here, also for a tab the click closed (no tab left to read).
viewHolds.remove(press.tab)
interacted(.click, tabID: press.tab)
if let target = press.target { interacted(.click, target: target) }
}

/// The person's next input after a move (a key-down; a press calls it too): every hold ends,
Expand Down
Loading
Loading