diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f643eb2a3454..3989c0f057d2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": { diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index 308535487577..5a9609e2810e 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -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 diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 21953b44d392..d132092afbd8 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -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`. diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 7b728fd70e71..18785726c967 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -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) @@ -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, @@ -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): @@ -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. @@ -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 diff --git a/cmuxTests/CloudTreeOneMachineManyWorkspacesTests.swift b/cmuxTests/CloudTreeOneMachineManyWorkspacesTests.swift index 1fbcbf684fb8..432abf412d0e 100644 --- a/cmuxTests/CloudTreeOneMachineManyWorkspacesTests.swift +++ b/cmuxTests/CloudTreeOneMachineManyWorkspacesTests.swift @@ -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) -> [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)