Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -80570,6 +80570,23 @@
}
}
},
"cloudTree.workspace.unread.help": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "A terminal in this workspace has a notification you have not read on this Mac"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このワークスペースのターミナルに、このMacでまだ読んでいない通知があります"
}
}
}
},
"cloudTree.terminal.unread.help": {
"extractionState": "manual",
"localizations": {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Cloud/CloudTreeCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ final class CloudTreeCellView: NSTableCellView {
}
#endif
displayHost.rootView = AnyView(
CloudTreeRowContentView(kind: node.kind, style: style)
CloudTreeRowContentView(kind: node.kind, hasUnreadDescendant: node.hasUnreadNotification, style: style)
.frame(maxWidth: .infinity, alignment: .leading)
)
// An in-place row reload reuses this cell; the new content can be wider
Expand Down
9 changes: 9 additions & 0 deletions Sources/Cloud/CloudTreeNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ final class CloudTreeNode: NSObject {

var isExpandable: Bool { !children.isEmpty }

/// True when this row, or any row beneath it, is a terminal whose machine
/// holds a notification this Mac has not read. Derived from the terminal
/// rows on every read, so a workspace or machine row can never show a dot
/// its terminals do not: the terminal flag is the only stored state.
var hasUnreadNotification: Bool {
if case .terminal(let row) = kind { return row.hasUnreadNotification }
return children.contains { $0.hasUnreadNotification }
}

/// The case of `kind` without its payload: what decides row height, menus,
/// expandability and drag-ability. Two trees with equal structure signatures
/// can be updated in place; a content-only change never needs `reloadData`.
Expand Down
52 changes: 39 additions & 13 deletions Sources/Cloud/CloudTreeRowContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,16 @@ enum CloudTreeIconPalette {
/// nothing here is interactive.
struct CloudTreeRowContentView: View {
let kind: CloudTreeNode.Kind
/// A terminal beneath this row has a notification this Mac has not read
/// (`CloudTreeNode.hasUnreadNotification`, derived from the terminal rows).
/// Workspace rows show it so a collapsed workspace still surfaces the dot.
var hasUnreadDescendant: Bool = false
var style: CloudTreeStyle = CloudTreeStyleStore.current

static var workspaceUnreadHelp: String {
String(localized: "cloudTree.workspace.unread.help", defaultValue: "A terminal in this workspace has a notification you have not read on this Mac")
}

private static func nonEmptyTrimmed(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down Expand Up @@ -95,7 +103,9 @@ struct CloudTreeRowContentView: View {
detail: style.showsGroupCounts
? CloudTreeRowContentView.count(terminalCount)
: nil
)
) {
CloudTreeUnreadDot(style: style, visible: hasUnreadDescendant, help: Self.workspaceUnreadHelp)
}
case .localWorkspace(let row):
CloudTreeLeafRow(
style: style,
Expand All @@ -104,7 +114,9 @@ struct CloudTreeRowContentView: View {
title: row.title,
titleWeight: row.isSelected ? .medium : .regular,
detail: style.showsGroupCounts ? CloudTreeRowContentView.count(row.terminalCount) : nil
)
) {
CloudTreeUnreadDot(style: style, visible: hasUnreadDescendant, help: Self.workspaceUnreadHelp)
}
case .terminal(let row):
CloudTreeTerminalRowContent(row: row, style: style)
case .display(let resource, _, let remoteView):
Expand Down Expand Up @@ -393,6 +405,26 @@ extension CloudTreeLeafRow where Accessories == EmptyView {
}
}

/// The per-client attention dot shared by terminal rows and the workspace rows
/// above them. The dot is always in the layout and only its opacity changes: an
/// in-place row refresh (reloadData(forRowIndexes:)) re-hosts the same SwiftUI
/// tree, and a structural insert there is not repainted.
struct CloudTreeUnreadDot: View {
let style: CloudTreeStyle
let visible: Bool
let help: String

var body: some View {
Circle()
.fill(Color.accentColor)
.frame(width: max(style.iconSize * 0.5, 6), height: max(style.iconSize * 0.5, 6))
.opacity(visible ? 1 : 0)
.accessibilityHidden(!visible)
.help(visible ? help : "")
.accessibilityLabel(visible ? help : "")
}
}

/// A cmux-tui terminal row: lifecycle glyph, title (a dim sparkle prefix when an
/// agent is running in it), dimmed cwd, an optional daemon-tab badge on pool
/// rows, and a dim "open" mark when a local pane is already showing it.
Expand Down Expand Up @@ -430,17 +462,11 @@ struct CloudTreeTerminalRowContent: View {
// always in the layout and only its opacity changes: an in-place
// row refresh (reloadData(forRowIndexes:)) re-hosts the same
// SwiftUI tree, and a structural insert there is not repainted.
Circle()
.fill(Color.accentColor)
.frame(width: max(style.iconSize * 0.5, 6), height: max(style.iconSize * 0.5, 6))
.opacity(row.hasUnreadNotification ? 1 : 0)
.accessibilityHidden(!row.hasUnreadNotification)
.help(row.hasUnreadNotification
? String(localized: "cloudTree.terminal.unread.help", defaultValue: "This terminal has a notification you have not read on this Mac")
: "")
.accessibilityLabel(row.hasUnreadNotification
? String(localized: "cloudTree.terminal.unread.help", defaultValue: "This terminal has a notification you have not read on this Mac")
: "")
CloudTreeUnreadDot(
style: style,
visible: row.hasUnreadNotification,
help: String(localized: "cloudTree.terminal.unread.help", defaultValue: "This terminal has a notification you have not read on this Mac")
)
if showsDetachedState {
// Zero views: still running on the machine, no daemon tab shows it.
// Greyed with a "detached" mark (austin, 2026-09-02 — reversing the
Expand Down
42 changes: 42 additions & 0 deletions cmuxTests/CloudTreeOneMachineManyWorkspacesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,48 @@ struct CloudTreeOneMachineManyWorkspacesTests {
)
}

/// The unread dot on a workspace row is not stored anywhere: it is read
/// off the terminal rows beneath it every time, so the workspace can never
/// disagree with its terminals (https://github.com/manaflow-ai/cmux/pull/12112
/// put the dot on terminal rows only, which hid it for a collapsed workspace).
@Test("A workspace row derives its unread dot from the terminals beneath it")
func workspaceUnreadDerivesFromItsTerminals() throws {
let main = workspace("ws_main", "main", index: 0, focused: true)
let side = workspace("ws_side", "side", index: 1)
let snapshot = SurfaceCatalogSnapshot(
machines: [info(workspaces: [main, side])],
resources: [terminal("term_a", in: [main]), terminal("term_b", in: [side])],
projections: []
)
func tree(unread: Set<String>) -> [CloudTreeNode] {
CloudTreeNodeBuilder.flattened(CloudTreeNodeBuilder.nodes(
machines: [fleetRow()], snapshot: snapshot, localWorkspaces: [],
unreadTerminalIDs: unread.isEmpty ? [:] : [machineID: unread], includeLocalMachine: false
))
}
func node(_ id: String, in nodes: [CloudTreeNode]) -> CloudTreeNode? { nodes.first { $0.id == id } }

let unread = tree(unread: ["term_b"])
let mainRow = try #require(node("machine:brave-otter/ws/ws_main", in: unread))
let sideRow = try #require(node("machine:brave-otter/ws/ws_side", in: unread))
#expect(!mainRow.hasUnreadNotification, "no unread terminal under main")
#expect(sideRow.hasUnreadNotification, "term_b under side is unread")
#expect(try #require(node("machine:brave-otter", in: unread)).hasUnreadNotification, "the machine derives it too")
let sideTerminal = try #require(sideRow.children.first { if case .terminal(let row) = $0.kind { return row.resource.id.key == "term_b" }; return false })
#expect(sideTerminal.hasUnreadNotification)

// Reading it on this Mac clears the terminal flag; the workspace follows
// with no second write, because it has no state of its own.
let read = tree(unread: [])
#expect(!(try #require(node("machine:brave-otter/ws/ws_side", in: read))).hasUnreadNotification)
#expect(!(try #require(node("machine:brave-otter", in: read))).hasUnreadNotification)

// Adopting a refreshed tree in place carries the derived value with it.
let stale = tree(unread: ["term_b"])
for (existing, replacement) in zip(stale, read) { existing.adopt(from: replacement) }
#expect(!(try #require(node("machine:brave-otter/ws/ws_side", in: stale))).hasUnreadNotification)
}

@Test("A machine with a single workspace keeps its Workspaces group row and the group's +")
func singleWorkspaceKeepsItsGroupRow() throws {
let main = workspace("ws_main", "main", index: 0, focused: true)
Expand Down
Loading