diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Sidebar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Sidebar.swift index 6a933376547f..04f8e78fa3be 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Sidebar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Sidebar.swift @@ -5,9 +5,10 @@ import CmuxNextDesign /// What a notification does to the sidebar, as classic did (hq#1870 P1 #11): /// its workspace moves to the top of the unpinned rows -/// (`notifications.attention.moveToTop`), and its row shows the text while it -/// is unread (`sidebar.workspaceRow.notification`; the text comes from the -/// daemon store, seeded here with the notifications posted before launch). +/// (`notifications.attention.moveToTop`), and its hover card shows the text +/// while it is unread (its row too with `sidebar.workspaceRow.notification` +/// on; the text comes from the daemon store, seeded here with the +/// notifications posted before launch). extension NotificationCenterService { /// Moves a notified workspace first in its group, else first among the /// loose rows, as Move Workspace to Top does. A pinned workspace, a muted diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonStore+NotificationText.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonStore+NotificationText.swift index f74cfee43da4..cd02e6c8adbf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonStore+NotificationText.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemon/Store/DaemonStore+NotificationText.swift @@ -1,8 +1,8 @@ import Foundation -/// The text of a tab's unread notification, which its workspace's sidebar -/// row shows (`sidebar.workspaceRow.notification`, classic's -/// `sidebar.showNotificationMessage`): the body, else the title, trimmed. +/// The text of a tab's unread notification, which its workspace's hover card +/// shows (and its sidebar row, with `sidebar.workspaceRow.notification` on, +/// classic's `sidebar.showNotificationMessage`): the body, else the title, trimmed. extension DaemonStore { /// How many live notifications keep their text; the oldest go first. static let notificationTextLimit = 512 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/SidebarCustomization.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/SidebarCustomization.swift index 0df6e3dae126..7572c05dd103 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/SidebarCustomization.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/SidebarCustomization.swift @@ -83,7 +83,7 @@ public nonisolated struct SidebarRowDecoration: Hashable, Sendable { /// hide or restyle the same fields without fetching anything (cx-swovq.1). public nonisolated struct SidebarCardFacts: Hashable, Sendable { public struct Fact: Hashable, Sendable { - /// `needsInput`, `agent`, `task`, `lastReply`, `presence`, `command`, `folder`, `branch`, + /// `needsInput`, `notification`, `agent`, `task`, `lastReply`, `presence`, `command`, `folder`, `branch`, /// `pullRequest`, `machine`, `status` (a reported status entry) or `more`. public var kind: String public var text: String diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/WorkspaceRowPreferences.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/WorkspaceRowPreferences.swift index ac28c478357c..ccec4940f92e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/WorkspaceRowPreferences.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/WorkspaceRowPreferences.swift @@ -17,9 +17,11 @@ public nonisolated struct WorkspaceRowElements: Hashable, Sendable { /// The default set: `minimal` plus what agents and scripts report /// (status entries, the log line, progress), which a row draws only for - /// a workspace that reported some (#16431, classic's status block), and - /// an unread notification's text, as classic shows it. - public static let standard = Self(shown: minimal.shown.union([.agentStatus, .log, .progress, .notification])) + /// a workspace that reported some (#16431, classic's status block). An + /// unread notification's text is opt-in (`notification`, classic's second + /// line): a compact row stays one line, its status glyph says the state + /// and the hover card says the text (Leo 2026-10-14; #19862 had it on). + public static let standard = Self(shown: minimal.shown.union([.agentStatus, .log, .progress])) public func shows(_ element: WorkspaceRowElement) -> Bool { shown.contains(element) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift index 0c2791c633a9..6edaa7fc9dc8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift @@ -44,6 +44,8 @@ struct WorkspaceHoverCardContent: Equatable { var facts: [Fact] = [] let detail = workspace.detail if let reason = detail.needsInput { facts.append(Fact(icon: .statusNeedsinput, text: reason, lines: 3, kind: "needsInput")) } + // The unread notification's text, which a compact row no longer draws by default. + if let text = workspace.notificationText, !text.isEmpty { facts.append(Fact(icon: .notification, text: text, lines: 2, kind: "notification")) } if let agent = workspace.agentName { facts.append(Fact(icon: .agentChat, text: [agent, detail.model].compactMap { $0 }.joined(separator: " ยท "), kind: "agent")) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarOneLineRowsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarOneLineRowsTests.swift new file mode 100644 index 000000000000..6e852b2baca9 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarOneLineRowsTests.swift @@ -0,0 +1,47 @@ +import AppKit +import CmuxNextDesign +@testable import CmuxNextSidebar +import Foundation +import Testing + +/// Compact rows are one line by default (Leo 2026-10-14: "why is it even 2 lines in the first +/// place?"). #19862 drew an unread notification's text under the row by default; the status +/// glyph now carries the state, and the text reads in the row's hover card. +/// `sidebar.workspaceRow.notification` turns the classic second line back on. +@MainActor +struct SidebarOneLineRowsTests { + private static let notified = WorkspaceID("ws-notified") + private static let text = "Claude finished: tests pass" + + private static var workspace: SidebarWorkspace { + var workspace = SidebarWorkspace(id: notified, title: "cmux-next", unread: .count(1), activity: .success) + workspace.notificationText = text + return workspace + } + + private static func sidebar(_ rows: WorkspaceRowPreferences = .defaults) -> SidebarView { + let machine = SidebarMachine(id: .local, name: "This Mac", kind: .local) + let model = SidebarModel(sections: [SidebarSection(kind: .machine(machine), nodes: [.workspace(workspace)])]) + model.workspaceRow = rows + let sidebar = SidebarView(model: model, design: DesignSettings()) + sidebar.frame = NSRect(x: 0, y: 0, width: 240, height: 400) + sidebar.layoutSubtreeIfNeeded() + return sidebar + } + + @Test func aNotifiedRowStaysOneLineAndItsHoverCardSaysTheText() throws { + let row = try #require(Self.sidebar().list.displayed.row(for: .workspace(Self.notified))) + #expect(row.height == SidebarLayoutMetrics.standard.rowHeight) + #expect(row.content?.detail == nil) + let card = WorkspaceHoverCardContent.make(Self.workspace, machine: nil, now: Date()) + #expect(card.facts.contains { $0.text == Self.text }) + } + + @Test func theNotificationSettingBringsTheSecondLineBack() throws { + var rows = WorkspaceRowPreferences.defaults + rows.base.shown.insert(.notification) + let row = try #require(Self.sidebar(rows).list.displayed.row(for: .workspace(Self.notified))) + #expect(row.height > SidebarLayoutMetrics.standard.rowHeight) + #expect(row.content?.detail == Self.text) + } +} diff --git a/docs/cmux-next/sidebar-customization.md b/docs/cmux-next/sidebar-customization.md index 7fb898e3f3ab..13b5ed524a7a 100644 --- a/docs/cmux-next/sidebar-customization.md +++ b/docs/cmux-next/sidebar-customization.md @@ -47,7 +47,8 @@ The manifest names the render function: | `age` | How long ago the workspace was last active ("4w") | | `facts` | `[{kind, text, sinceMs}]`, most useful first | -Fact kinds: `needsInput` (why it waits, in the program's words), `agent` (agent and model), +Fact kinds: `needsInput` (why it waits, in the program's words), `notification` (the unread +notification's text, which a row shows only with Latest Notification on), `agent` (agent and model), `task` (the agent's prompt, or what a working program says it does), `lastReply`, `presence` (what the agent edits or reads now), `command` (the running shell command; `sinceMs` is its start), `folder`, `branch`, `pullRequest`, `machine`, `status` (a reported status entry), `more`. diff --git a/docs/cmux-next/sidebar-customization/main.js b/docs/cmux-next/sidebar-customization/main.js index 79088b724de6..b3f2beb15f0d 100644 --- a/docs/cmux-next/sidebar-customization/main.js +++ b/docs/cmux-next/sidebar-customization/main.js @@ -4,10 +4,10 @@ // ---- Hover card -------------------------------------------------------------------------- // The fields the card shows, in this order. Remove a name to hide that field. -const FIELDS = ["needsInput", "task", "agent", "command", "presence", "lastReply", "folder", "branch", "pullRequest", "machine", "status"] +const FIELDS = ["needsInput", "notification", "task", "agent", "command", "presence", "lastReply", "folder", "branch", "pullRequest", "machine", "status"] const ICONS = { - needsInput: "questionmark.bubble", agent: "sparkles", task: "text.alignleft", lastReply: "text.bubble", + needsInput: "questionmark.bubble", notification: "bell", agent: "sparkles", task: "text.alignleft", lastReply: "text.bubble", presence: "pencil", command: "terminal", folder: "folder", branch: "arrow.triangle.branch", pullRequest: "arrow.triangle.pull", machine: "desktopcomputer", status: "circle.fill", more: "ellipsis", } diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index ac160df74026..cfa4f00ac6a5 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -2523,7 +2523,7 @@ "type" : "boolean" }, "sidebar.workspaceRow.notification" : { - "default" : true, + "default" : false, "description" : "The text of the workspace's newest unread notification.", "title" : "Latest Notification", "type" : "boolean" diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index d7d48d2efa46..39cb02a23fcb 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -3006,7 +3006,7 @@ pfm_default - + pfm_description The text of the workspace's newest unread notification. pfm_name diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index 86ec9131fc96..63017852ecaa 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -200,7 +200,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `sidebar.workspaceRow.progress` | boolean | `true` | | Progress. A progress bar under the row and the busy mark of running work. | | `sidebar.workspaceRow.working` | boolean | `true` | | Agent Working. A mark while an agent works in the workspace. | | `sidebar.workspaceRow.log` | boolean | `true` | | Log. The newest line agents and scripts log: cmux workspace log append . | -| `sidebar.workspaceRow.notification` | boolean | `true` | | Latest Notification. The text of the workspace's newest unread notification. | +| `sidebar.workspaceRow.notification` | boolean | `false` | | Latest Notification. The text of the workspace's newest unread notification. | | `sidebar.workspaceRow.secondLineOrder` | array | `["notification","directory","branch","process","agentStatus","ports","lastActivity"]` | `notification`, `directory`, `branch`, `process`, `agentStatus`, `ports`, `lastActivity` | Second Line Order. The order of the shown items under the workspace name. | | `sidebar.workspaceRow.terminal.icon` | boolean | | | Icon | | `sidebar.workspaceRow.terminal.directory` | boolean | | | Folder | diff --git a/schemas/defaults/cmux-next.json b/schemas/defaults/cmux-next.json index 9ff51a96c973..3010f245e083 100644 --- a/schemas/defaults/cmux-next.json +++ b/schemas/defaults/cmux-next.json @@ -359,6 +359,7 @@ "setting.focusRing.showWhenSinglePane": false, "setting.focusRing.style": "ring", "setting.focusRing.width": 1, + "setting.freeModel.enabled": true, "setting.history.closed.maxAgeDays": 0, "setting.history.closed.maxGroups": 0, "setting.history.commandRetentionDays": 30, @@ -492,7 +493,7 @@ "setting.sidebar.workspaceRow.mixed.secondLineOrder": null, "setting.sidebar.workspaceRow.mixed.tabCount": null, "setting.sidebar.workspaceRow.mixed.working": null, - "setting.sidebar.workspaceRow.notification": true, + "setting.sidebar.workspaceRow.notification": false, "setting.sidebar.workspaceRow.ports": false, "setting.sidebar.workspaceRow.process": false, "setting.sidebar.workspaceRow.progress": true, diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index 7829a13ae635..86e1c278e6c7 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -15367,7 +15367,7 @@ "consumers" : [ "cmux-next" ], - "default" : true, + "default" : false, "default_label" : null, "group" : { "key" : "settings.group.workspaceRows", @@ -23532,7 +23532,7 @@ "validation" : "portable" } ], - "schema_hash" : "500801fb8cd18e1a7b6cfd27819cf8052f8352c46c0ec241deb629be208146c1", + "schema_hash" : "79e569d3cdd546084d1ee8033b78707b55524c03716992d0a53c7b6a4b473314", "sections" : [ { "id" : "general", diff --git a/scripts/cmux-next/hovercard-live.py b/scripts/cmux-next/hovercard-live.py index 2852cd747661..720ac52d2a3a 100755 --- a/scripts/cmux-next/hovercard-live.py +++ b/scripts/cmux-next/hovercard-live.py @@ -244,7 +244,7 @@ def customize_checks(folder): # Hot reload: the folder first, the command hidden. with open(main, "w") as script: - script.write(template.replace('const FIELDS = ["needsInput", "task", "agent", "command", "presence", "lastReply", "folder", "branch", "pullRequest", "machine", "status"]', + script.write(template.replace('const FIELDS = ["needsInput", "notification", "task", "agent", "command", "presence", "lastReply", "folder", "branch", "pullRequest", "machine", "status"]', 'const FIELDS = ["folder", "task"]')) def reordered(text): ls = card_lines(text) diff --git a/scripts/cmux-next/sidebar-notification-e2e.py b/scripts/cmux-next/sidebar-notification-e2e.py index bb643479c677..bd7d634ab6f1 100755 --- a/scripts/cmux-next/sidebar-notification-e2e.py +++ b/scripts/cmux-next/sidebar-notification-e2e.py @@ -3,11 +3,12 @@ cx-sv425), on a running tagged cmux-next build. Classic shows the latest unread notification's text under its workspace's row -(`sidebar.showNotificationMessage`, on by default) and moves the workspace to the top of the +(`sidebar.showNotificationMessage`, on by default; in cmux NEXT `sidebar.workspaceRow.notification`, +off by default, so the row stays one line and the hover card says the text) and moves the workspace to the top of the unpinned rows (`app.reorderOnNotification`, on by default). The script makes two workspaces, leaves the one it selects alone, posts a notification to the other (the bottom row) through the daemon, as `cmux notify` does, and reads the rows back from `debug.sidebar_rows`: the notified -workspace is the top row and its row draws the notification's text. A notification for the +workspace is the top row and its row stays one line. A notification for the workspace the user is looking at moves nothing. The script attaches to an app already running (a capture slot's `capture-host launch`, or a @@ -148,10 +149,11 @@ def main(): after = titles(rows) check("the notified workspace moves to the top row", after[:1] == [target], f"before={before.index(target)} after={after.index(target) if target in after else None} rows={after}") - row = next((row for row in rows if row.get("title") == target), {}) - rows, _ = wait_rows(lambda rows: body in str(next((r for r in rows if r.get("title") == target), {}).get("detail"))) + # The row stays one line by default (Leo 2026-10-14); the text reads in its hover card, and + # `sidebar.workspaceRow.notification` draws it under the row as classic did. row = next((r for r in rows if r.get("title") == target), {}) - check("its row shows the notification's text", body in str(row.get("detail")), f"detail={row.get('detail')!r}") + check("its row stays one line, without the notification's text", body not in str(row.get("detail")), + f"detail={row.get('detail')!r}") # A notification for the selected workspace (the one in view) leaves the order alone. _, selection = sidebar()