Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 2 additions & 1 deletion docs/cmux-next/sidebar-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions docs/cmux-next/sidebar-customization/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
2 changes: 1 addition & 1 deletion docs/mdm/com.manaflow.cmux.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion docs/mdm/com.manaflow.cmux.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3006,7 +3006,7 @@
</dict>
<dict>
<key>pfm_default</key>
<true/>
<false/>
<key>pfm_description</key>
<string>The text of the workspace's newest unread notification.</string>
<key>pfm_name</key>
Expand Down
2 changes: 1 addition & 1 deletion docs/mdm/managed-preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>. |
| `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 |
Expand Down
3 changes: 2 additions & 1 deletion schemas/defaults/cmux-next.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions schemas/settings/settings-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -15367,7 +15367,7 @@
"consumers" : [
"cmux-next"
],
"default" : true,
"default" : false,
"default_label" : null,
"group" : {
"key" : "settings.group.workspaceRows",
Expand Down Expand Up @@ -23532,7 +23532,7 @@
"validation" : "portable"
}
],
"schema_hash" : "500801fb8cd18e1a7b6cfd27819cf8052f8352c46c0ec241deb629be208146c1",
"schema_hash" : "79e569d3cdd546084d1ee8033b78707b55524c03716992d0a53c7b6a4b473314",
"sections" : [
{
"id" : "general",
Expand Down
2 changes: 1 addition & 1 deletion scripts/cmux-next/hovercard-live.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions scripts/cmux-next/sidebar-notification-e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading