Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
38580d7
tabs: keep plus actions plain and add workspace chooser
teamleaderleo Oct 28, 2026
7dde88e
test: import sidebar design settings
teamleaderleo Oct 28, 2026
85b7d8b
fix(next): make workspace chooser keyboard accessible
teamleaderleo Oct 28, 2026
5ed4ec8
docs(next): log workspace chooser sidebar look
teamleaderleo Oct 28, 2026
f2acd82
test(next): require distinct pressed ink on a hovered tab plus
teamleaderleo Oct 28, 2026
cce97d2
fix(next): show pressed ink on plain tab plus
teamleaderleo Oct 28, 2026
4543f72
fix(next): distinguish held plus glyphs in neutral themes
teamleaderleo Oct 28, 2026
304921f
fix(next): dispatch titlebar accessibility through button actions
teamleaderleo Oct 28, 2026
e0d0ce1
Merge feat-cmux-next and preserve shared sidebar reveal policy
teamleaderleo Oct 28, 2026
546bdea
Merge feat-cmux-next and keep update control in sidebar footer
teamleaderleo Oct 28, 2026
e384eb2
Merge branch 'feat-cmux-next' into rail-topright-plus-plain
cmux-bot[bot] Oct 28, 2026
2f327e5
Merge feat-cmux-next into rail-topright-plus-plain
teamleaderleo Oct 29, 2026
c3a410f
Merge remote-tracking branch 'origin/feat-cmux-next' into rail-toprig…
teamleaderleo Oct 29, 2026
5b5cdb8
Sidebar: keep the + and its chevron revealed and lit while their menu…
teamleaderleo Oct 29, 2026
0d65861
Sidebar tests: check the plain + by its press, not its off-window AX …
teamleaderleo Oct 29, 2026
9870e73
Sidebar: localize the chevron's label; hide it before it reaches the …
teamleaderleo Oct 29, 2026
a094d32
Sidebar: probe NSButton willOpenMenu/didCloseMenu; log the PR's untra…
teamleaderleo Oct 29, 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 @@ -38,9 +38,9 @@ final class ScreenBarController {
preview = ScreenPreviewSource(content: content)
view.dragsWindowFromEmptySpace = false
view.renamesOnDoubleClick = true
// The one + with a caret (Lawrence 2026-10-27: "caret should maybe just be in this one"): a press
// opens the screen menu. Set before the tooltip, which the menu switch also writes.
view.opensNewTabMenuOnPress = true
// The screen bar's + is a plain one-click new-screen action. Holding or right-clicking
// still exposes the existing screen type menu through the strip's context path.
view.opensNewTabMenuOnPress = false
view.newTabToolTip = nil // the screen bar's + makes a screen, not a tab
view.previewProvider = preview
view.setAccessibilityLabel(ScreenStrings.barAccessibility)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,28 +70,57 @@ public final class TitlebarBandButton: NSButton {
menu.popUp(positioning: nil, at: CmuxPopoverAnchor.menuPoint(in: button, gap: Metrics.space1), in: button)
}

/// Told `true` as a menu this button opened (press, long-press or right-click) shows and
/// `false` once it closes, so a host that fades the button on hover keeps it revealed while
/// its menu is open (Leo, 2026-10-26: a hover-revealed control stays revealed and lit while
/// the menu it opened is open).
public var onMenuOpenChange: ((Bool) -> Void)?

/// Shows `menu` with the button pressed (lit) for as long as it is open.
private func showMenu(_ show: () -> Void) {
isHighlighted = true
hover.state.pressed = true
onMenuOpenChange?(true)
defer {
isHighlighted = false
hover.state.pressed = false
onMenuOpenChange?(false)
}
show()
}

/// Opens the press menu for keyboard and VoiceOver activation. Mouse presses
/// use the same path from ``mouseDown(with:)``.
public func performPressMenu() {
guard isEnabled, let menu = pressMenu?(), !menu.items.isEmpty else { return }
showMenu { presentMenu(menu, self) }
}

override public func accessibilityPerformPress() -> Bool {
guard isEnabled, !isHiddenOrHasHiddenAncestor else { return false }
guard pressMenu != nil else { return sendAction(action, to: target) }
performPressMenu()
return true
}

override public func rightMouseDown(with event: NSEvent) {
guard isEnabled else { return }
guard let menu = menuProvider?() else { return super.rightMouseDown(with: event) }
NSMenu.popUpContextMenu(menu, with: event, for: self)
showMenu { NSMenu.popUpContextMenu(menu, with: event, for: self) }
}

/// A long press shows the menu like a browser's Back button; a click runs.
override public func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
if let pressMenu {
guard let menu = pressMenu(), !menu.items.isEmpty else { return }
// Pressed while its menu is open, like a pop-up button.
isHighlighted = true
defer { isHighlighted = false }
presentMenu(menu, self)
if pressMenu != nil {
performPressMenu()
return
}
guard menuProvider != nil, longPressOpensMenu, let window else { return super.mouseDown(with: event) }
let deadline = Date().addingTimeInterval(NSEvent.doubleClickInterval)
while let next = window.nextEvent(matching: [.leftMouseUp, .leftMouseDragged], until: deadline, inMode: .eventTracking, dequeue: true) {
if next.type == .leftMouseUp { sendAction(action, to: target); return }
}
if let menu = menuProvider?() { presentMenu(menu, self) }
if let menu = menuProvider?() { showMenu { presentMenu(menu, self) } }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28439,6 +28439,137 @@
}
}
}
},
"sidebar.newWorkspace.options": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "New Workspace Options"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "新規ワークスペースのオプション"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "خيارات مساحة العمل الجديدة"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Opcije novog radnog prostora"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Indstillinger for nyt arbejdsområde"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Optionen für neuen Arbeitsbereich"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Opciones de nuevo espacio de trabajo"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Options du nouvel espace de travail"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Opzioni nuova area di lavoro"
}
},
"km": {
"stringUnit": {
"state": "needs_review",
"value": "ជម្រើសកន្លែងធ្វើការថ្មី"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "새 작업 공간 옵션"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Alternativer for nytt arbeidsområde"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Opcje nowej przestrzeni roboczej"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Opções de Nova Área de Trabalho"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Параметры нового рабочего пространства"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "ตัวเลือกเวิร์กสเปซใหม่"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Yeni Çalışma Alanı Seçenekleri"
}
},
"uk": {
"stringUnit": {
"state": "translated",
"value": "Параметри нової робочої області"
}
},
"vi": {
"stringUnit": {
"state": "needs_review",
"value": "Tùy chọn không gian làm việc mới"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "新建工作区选项"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "新增工作區選項"
}
}
}
}
},
"version": "1.0"
Expand Down
4 changes: 4 additions & 0 deletions Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ enum Strings {
static var closeRefused: String { String(localized: "sidebar.close.refused", defaultValue: "The workspace could not be closed.", bundle: .module) }
static var dismissCard: String { String(localized: "sidebar.card.dismiss", defaultValue: "Dismiss", bundle: .module) }
static var newWorkspace: String { String(localized: "sidebar.newWorkspace", defaultValue: "New Workspace", bundle: .module) }
/// The chevron beside the sidebar's +: its VoiceOver label (it opens the workspace chooser).
static var newWorkspaceOptions: String {
String(localized: "sidebar.newWorkspace.options", defaultValue: "New Workspace Options", bundle: .module)
}
/// The footer's Search button: its VoiceOver label and tooltip.
static var searchButton: String { String(localized: "sidebar.search", defaultValue: "Search", bundle: .module) }
/// The thin rail's VoiceOver label (`sidebar.style` = rail).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ final class SidebarIconButton: NSButton {
menuProvider?() ?? super.menu(for: event)
}

/// Told `true` while a right-click menu this button opened is open, `false` once it closes
/// (the sidebar keeps its hover-revealed + shown meanwhile).
var onMenuOpenChange: ((Bool) -> Void)?

/// The button stays pressed (lit) while its own menu is open, after the pointer leaves it.
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
changeHover { $0.pressed = true }
onMenuOpenChange?(true)
}

override func didCloseMenu(_ menu: NSMenu, with event: NSEvent?) {
super.didCloseMenu(menu, with: event)
changeHover { $0.pressed = false }
onMenuOpenChange?(false)
}

private func renderIcon() {
let size = pointSize()
guard size != renderedSize else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ extension SidebarViewComponents {
/// footer's update pill stays visible (the only update notice). The top
/// band's hairline fades with its band (Lawrence 2026-10-05).
func setChromeRevealed(_ revealed: Bool) {
// A menu the + or its chevron opened holds them revealed (and lit) until it closes, even
// after the pointer leaves for the menu (Leo, 2026-10-26).
let revealed = revealed || chromeMenuHolds > 0
let changed = revealed != isChromeRevealed
isChromeRevealed = revealed
for region in bandRegions { region.chromeRevealed = revealed }
Expand All @@ -30,6 +33,7 @@ extension SidebarViewComponents {
// A fade only, under Reduce Motion at once (the gate snaps): every piece keeps its frame.
Motion.feedback.animate(.hover, in: self) {
newButton.animator().alphaValue = target.newButton
newWorkspaceOptionsButton.animator().alphaValue = target.newButton
profileBar.animator().alphaValue = target.spaces
aboveFade.animator().alphaValue = target.topBand
belowFade.animator().alphaValue = belowRegion.restAlpha(hiddenByMode: target.bottomBand == 0)
Expand All @@ -39,6 +43,19 @@ extension SidebarViewComponents {
fadeLine(aboveLine, to: target.topBand)
}

/// A menu from a hover-revealed titlebar control opened (`true`) or closed (`false`). On the
/// last close the reveal goes back to the pointer, which may have left meanwhile.
func chromeMenuOpenChanged(_ open: Bool) {
if open {
chromeMenuHolds += 1
setChromeRevealed(true)
} else {
chromeMenuHolds = max(0, chromeMenuHolds - 1)
chromeHover?.refresh()
setChromeRevealed(chromeHover?.isHovering ?? false)
}
}

/// What each piece of the hover chrome shows with the pointer over the sidebar (`revealed`) or
/// away, under the current settings (``SidebarChromeReveal``).
func chromeReveal(revealed: Bool) -> SidebarChromeReveal {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public final class SidebarView: NSView {
/// footer has none (SIDEBAR-FOOTER-MINIMAL).
let aboveLine = CALayer()
let newButton = SidebarIconButton(icon: .actionAdd, label: Strings.newWorkspace)
/// A separate compact chooser control: the + itself remains a one-click workspace action.
let newWorkspaceOptionsButton = TitlebarBandButton(symbol: "chevron.down")
/// The footer's Search button, after the profile avatar (plans/cmux-next/sidebar-search.md):
/// a press runs ``onSearch`` (the centered Search palette). `sidebar.showSearchButton` hides it.
let searchButton = SidebarIconButton(icon: .search, pointSize: { Metrics.iconSize }, label: Strings.searchButton)
Expand All @@ -72,6 +74,9 @@ public final class SidebarView: NSView {
var priority: SidebarPriorityPanel?
/// Pointer over the sidebar (or a tab drag over it): titlebar buttons show.
var isChromeRevealed = false
/// Menus opened from the hover-revealed titlebar controls that are open now: the chrome
/// stays revealed until they close (``chromeMenuOpenChanged(_:)``).
var chromeMenuHolds = 0
/// The pointer over the sidebar now (cx-3wu5).
private(set) var chromeHover: PointerHover?
/// Bands minimal mode hides right now (the fade's target, R54).
Expand Down Expand Up @@ -115,6 +120,8 @@ public final class SidebarView: NSView {
super.init(frame: .zero)
buildHierarchy()
newButton.setAccessibilityIdentifier("cmux.sidebar.newWorkspace")
newWorkspaceOptionsButton.setAccessibilityLabel(Strings.newWorkspaceOptions)
newWorkspaceOptionsButton.setAccessibilityIdentifier("cmux.sidebar.newWorkspace.options")
list.reload(animated: false)
observe()
// The chrome reveal follows the pointer and the sidebar's frame (cx-3wu5).
Expand Down Expand Up @@ -223,9 +230,18 @@ public final class SidebarView: NSView {
// MARK: Hierarchy

private func buildHierarchy() {
newButton.onPress = { [weak self] in self?.model.send(.newWorkspace(machine: nil, group: nil)) }; newButton.menuProvider = { [weak self] in self?.list.contextMenuProvider?(.background) }
newButton.onPress = { [weak self] in self?.model.send(.newWorkspace(machine: nil, group: nil)) }
newButton.menuProvider = { [weak self] in self?.list.contextMenuProvider?(.background) }
newWorkspaceOptionsButton.longPressOpensMenu = false
newWorkspaceOptionsButton.pressMenu = { [weak self] in self?.list.contextMenuProvider?(.background) }
newWorkspaceOptionsButton.target = self
newWorkspaceOptionsButton.action = #selector(openNewWorkspaceOptions)
newButton.onMenuOpenChange = { [weak self] open in self?.chromeMenuOpenChanged(open) }
newWorkspaceOptionsButton.onMenuOpenChange = { [weak self] open in self?.chromeMenuOpenChanged(open) }
newButton.alphaValue = chromeReveal(revealed: false).newButton
newWorkspaceOptionsButton.alphaValue = newButton.alphaValue
addSubview(newButton)
addSubview(newWorkspaceOptionsButton)

scrollView.drawsBackground = false
scrollView.hasVerticalScroller = true
Expand Down Expand Up @@ -261,6 +277,10 @@ public final class SidebarView: NSView {
buildUpdateAvailable()
}

@objc private func openNewWorkspaceOptions() {
newWorkspaceOptionsButton.performPressMenu()
}

private func clipBoundsChanged() {
list.realizeVisibleRows()
// The list updates row hover as rows move. A window-wide refresh here
Expand Down Expand Up @@ -289,8 +309,16 @@ public final class SidebarView: NSView {
let button = SidebarStyle.toolbarButtonSize
let rowMidY = window.flatMap(WindowTitlebar.trafficLightsFrame(in:)).map { convert($0, from: nil).midY } ?? titlebarHeight / 2
newButton.frame = NSRect(x: b.width - Metrics.space3 - button, y: (rowMidY - button / 2).rounded(), width: button, height: button)
newWorkspaceOptionsButton.frame = NSRect(x: newButton.frame.minX - Metrics.space1 - button,
y: newButton.frame.minY, width: button, height: button)
let x = headerHasWindowControls ? max(Metrics.trafficLightInset, titlebarLeadingReserve) : titlebarLeadingReserve
let titlebarButtonsMinX = layoutPriorityButton(before: newButton.frame)
// The chevron is the optional extra (the +'s right-click menu holds the same chooser): on a
// sidebar too narrow for it and the Priority button beside the +, it hides before either one
// reaches the leading reserve (the toolbar band sits above it there and takes the clicks).
let optionsMinX = newWorkspaceOptionsButton.frame.minX - (priorityButton.isHidden ? 0 : Metrics.space1 + button)
newWorkspaceOptionsButton.isHidden = optionsMinX < x
let titlebarButtonsMinX = layoutPriorityButton(before: newWorkspaceOptionsButton.isHidden ? newButton.frame
: newWorkspaceOptionsButton.frame)
if let accessory = titlebarAccessory {
let size = accessory.fittingSize
let width = max(0, min(size.width, titlebarButtonsMinX - Metrics.space2 - x))
Expand Down Expand Up @@ -372,7 +400,7 @@ extension SidebarView: TitlebarPressDeciding {
public func titlebarPress(atWindowPoint windowPoint: CGPoint) -> TitlebarPress {
let point = convert(windowPoint, from: nil)
guard point.y < titlebarHeight else { return .staysPut }
let controls = [newButton as NSView?, priorityButton, titlebarAccessory].compactMap { $0 }.filter { !$0.isHidden }
let controls = [newButton as NSView?, newWorkspaceOptionsButton, priorityButton, titlebarAccessory].compactMap { $0 }.filter { !$0.isHidden }
return controls.contains { $0.frame.contains(point) } ? .staysPut : .movesWindow
}
}
Loading
Loading