Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4cb0186
test(next): a row dropped on Home's right or bottom edge shows its wo…
teamleaderleo Oct 21, 2026
e638e76
fix(next): multi-view on by default; a row dropped on Home's right or…
teamleaderleo Oct 22, 2026
72e022d
test(next media): a multi-view tour; debug.mouse drags a sidebar row …
teamleaderleo Oct 22, 2026
465f02c
revert(next): keep sidebar.multiView off by default; the multi-view t…
teamleaderleo Oct 22, 2026
de8159c
Merge origin/feat-cmux-next 4ae5de440131 into multiview-default-on
teamleaderleo Oct 22, 2026
d7d0749
fix(next): a Home edge drop goes beside the workspace on screen under…
teamleaderleo Oct 22, 2026
224bfcf
test(next): the Home bottom-edge drop in the dock band is 8 pt in, wh…
teamleaderleo Oct 22, 2026
fa9746b
test(next media): the multi-view tour drops api beside Home with plai…
teamleaderleo Oct 22, 2026
42814b4
test(next): the Home multi-view suite's daemon has edge docks, so the…
teamleaderleo Oct 22, 2026
1d5c343
Merge feat-cmux-next into multiview-default-on
teamleaderleo Oct 22, 2026
aa23d2a
Merge remote-tracking branch 'origin/feat-cmux-next' into multiview-d…
teamleaderleo Oct 23, 2026
5ff6e19
Merge remote-tracking branch 'origin/feat-cmux-next' into multiview-d…
teamleaderleo Oct 25, 2026
6f89807
fix(cmux-next): debug.mouse row points compile in the bench configura…
teamleaderleo Oct 25, 2026
933ceb5
Merge branch 'feat-cmux-next' into multiview-default-on
teamleaderleo Oct 25, 2026
e56998e
fix(cmux-next): SidebarMultiViewHomeTests names the edge-docks capabi…
teamleaderleo Oct 25, 2026
242c1fb
Merge branch 'feat-cmux-next' into multiview-default-on
teamleaderleo Oct 26, 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 @@ -2,6 +2,7 @@ import AppKit
import CmuxNextBridge
import CmuxNextDaemon
import CmuxNextDesign
import CmuxNextSidebar

// A sidebar workspace dropped on a pane of the shown workspace (Leo
// 2026-10-06): its tabs come in the way a tab drag would bring one. The
Expand Down Expand Up @@ -40,6 +41,11 @@ enum WorkspaceMerge {
layout: LayoutTabDropTarget, services: AppServices) -> TabDropProposal? {
guard let id = ids.first, allowed(ids, group: group, into: window.content(atScreen: point)?.workspace, services: services),
let proposal = layout.workspaceHitTest(screenPoint: point, workspaceID: id), outcome(proposal.kind) != nil else { return nil }
// Multi-view: a screen edge dock band places the workspace beside the shown one, not in a new dock of it.
if SidebarTunables.multiView.value, case .newDock = proposal.kind {
layout.dropExited()
return nil
}
return proposal
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ extension TabDragSession {
drag.workspaceOutcome = WorkspaceDragResolver.outcome(
windowID: controller?.state.id, sidebarHit: sidebarHit, layoutHit: layoutHit, placesBeside: SidebarTunables.multiView.value, sourceWindowID: sourceID,
draggingAllOfSource: !sourceMembers.isEmpty && Set(ids).isSuperset(of: sourceMembers),
isGroup: group != nil, screenPoint: point
isGroup: group != nil, screenPoint: point, besideEdge: controller?.besideEdge(at: point, dragging: ids, group: group)
)
// Never across incognito and normal windows: no drop target there.
if case .window(let id, _) = drag.workspaceOutcome, services.windows.registry.value.crossesIncognito(ids, to: id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,19 @@ enum WorkspaceDragResolver {
/// one instead of merging it (`sidebar.multiView`).
/// - isGroup: the drag carries a whole workspace group (never placed
/// at a slot or into another group).
/// - besideEdge: the shown workspace's edge near the pointer (`WindowController.besideEdge`). With
/// `placesBeside` it places the workspace beside where the layout has no pane target: a pane that takes no
/// drop (Home's pinned dock) or a screen edge dock band (`WorkspaceMerge.hit`).
static func outcome(windowID: String?, sidebarHit: WorkspaceDropTarget?, layoutHit: TabDropKind? = nil, placesBeside: Bool = false,
sourceWindowID: String?, draggingAllOfSource: Bool, isGroup: Bool, screenPoint: CGPoint) -> WorkspaceDragOutcome {
sourceWindowID: String?, draggingAllOfSource: Bool, isGroup: Bool, screenPoint: CGPoint,
besideEdge: TabDropEdge? = nil) -> WorkspaceDragOutcome {
guard let windowID else {
return draggingAllOfSource ? .moveWindow(screenPoint: screenPoint) : .newWindow(screenPoint: screenPoint)
}
// A group keeps its members together: it joins the window as a
// whole, where its own position in the daemon order puts it.
let target: WorkspaceDropTarget = isGroup ? .window : sidebarHit ?? layoutHit.map { pane(target: $0, placesBeside: placesBeside) } ?? .window
let beside = placesBeside ? besideEdge.map(WorkspaceDropTarget.beside) : nil
let target: WorkspaceDropTarget = isGroup ? .window : sidebarHit ?? layoutHit.map { pane(target: $0, placesBeside: placesBeside) } ?? beside ?? .window
if windowID == sourceWindowID {
// Back home: only a sidebar slot (a reorder) or a pane (a
// merge) means something.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#if DEBUG || CMUX_BENCH_HOOKS
import AppKit
import CmuxNextBridge
import CmuxNextSettings

/// `debug.mouse` points named by what is under them, so a tour can drag a sidebar row onto the shown workspace on any
/// runner's window size (`scripts/cmux-next/media/tours/multi-view.json`): `row` (a workspace's name or id) is the
/// center of its sidebar row; a `drag`'s `to_edge` (`left`, `right`, `top`, `bottom`) is 10% in from that edge of the
/// shown workspace's content, where a row dropped with `sidebar.multiView` goes beside it.
extension DebugMouse {
/// The center of the sidebar row of the workspace named or keyed `name` (top-left window-local points).
static func rowPoint(_ name: String, controller: WindowController, window: NSWindow) -> NSPoint? {
let services = controller.services
let members = services.windows.registry.members(of: controller.state.id)
guard let id = members.first(where: { $0 == name }) ?? members.first(where: { services.workspace(id: $0)?.name == name }) else { return nil }
guard let screen = controller.sidebar.container.sidebarView.rowFrameOnScreen(for: SidebarWorkspaceID(id)) else { return nil }
let center = window.convertPoint(fromScreen: NSPoint(x: screen.midX, y: screen.midY))
return InputJournal.topLeftPoint(center, in: window, relativeTo: window)
}

/// Where a `drag` ends: `to_x`,`to_y`, or 10% in from the shown workspace's `to_edge`; else where it started.
static func dragTarget(_ params: [String: JSONValue], from point: NSPoint, controller: WindowController, window: NSWindow) -> NSPoint {
if let x = params["to_x"]?.doubleValue, let y = params["to_y"]?.doubleValue { return NSPoint(x: x, y: y) }
guard let edge = params["to_edge"]?.stringValue, let view = controller.content?.contentView else { return point }
let r = view.convert(view.bounds, to: nil) // window coordinates, y up
let (dx, dy) = (r.width * 0.1, r.height * 0.1)
let at: NSPoint? = switch edge {
case "left": NSPoint(x: r.minX + dx, y: r.midY)
case "right": NSPoint(x: r.maxX - dx, y: r.midY)
case "top": NSPoint(x: r.midX, y: r.maxY - dy)
case "bottom": NSPoint(x: r.midX, y: r.minY + dy)
default: nil
}
return at.map { InputJournal.topLeftPoint($0, in: window, relativeTo: window) } ?? point
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import CmuxNextSettings
///
/// Params: `window` (id; default the first window); a point as `x`,`y`
/// (window-local points from the top-left, as the journal records them) or
/// `pane` (the center of that pane's content); `action`: `click` (default),
/// `double_click`, `down`, `up`, `drag` (to `to_x`,`to_y` in `steps`),
/// `move` (pointer motion with no button, for hover: tab and workspace
/// `pane` (the center of that pane's content) or `row` (a sidebar workspace row, `DebugMouse+Rows`); `action`: `click` (default),
/// `double_click`, `down`, `up`, `drag` (to `to_x`,`to_y`
/// or `to_edge` of the shown workspace, in `steps`), `move` (pointer motion with no button, for hover: tab and workspace
/// hover cards), `hover` (tracking-area owners get entered, moved and
/// exited at once, `DebugHover`: the tab strip's hover reveal), or `scroll`
/// (`dx`,`dy` pixels; `phase` began/changed/ended makes it a trackpad
Expand All @@ -41,7 +41,7 @@ enum DebugMouse {
guard let controller = services.windows.controllers.first(where: { windowID == nil || $0.state.id == windowID }),
let window = controller.window else { return .object(["error": .string("no window")]) }
guard let point = point(params, controller: controller, window: window) else {
return .object(["error": .string("pass x and y, or a pane shown in the window")])
return .object(["error": .string("pass x and y, a pane shown in the window, or a workspace row in its sidebar")])
}
let flags = modifiers(params["modifiers"]?.arrayValue ?? [])
let (down, up, dragged) = eventTypes(button: params["button"]?.stringValue)
Expand All @@ -59,7 +59,7 @@ enum DebugMouse {
case "up":
events = [mouse(up, at: point, in: window, flags: flags, clicks: 1)]
case "drag":
let target = NSPoint(x: params["to_x"]?.doubleValue ?? point.x, y: params["to_y"]?.doubleValue ?? point.y)
let target = dragTarget(params, from: point, controller: controller, window: window)
let steps = min(max(params["steps"]?.intValue ?? 12, 1), 200)
events = params["press"]?.boolValue == false ? [] : [mouse(down, at: point, in: window, flags: flags, clicks: 1)]
for step in 1...steps {
Expand Down Expand Up @@ -128,7 +128,7 @@ enum DebugMouse {
guard posted.count == events.count else { return .object(["error": .string("could not synthesize events")]) }
// The layout's hover reads the pointer, not events: the synthesized
// pointer ends where the last event is (cx-ww20).
let last = action == "drag" ? NSPoint(x: params["to_x"]?.doubleValue ?? point.x, y: params["to_y"]?.doubleValue ?? point.y) : point
let last = action == "drag" ? dragTarget(params, from: point, controller: controller, window: window) : point
if action != "scroll" || params["hover"]?.boolValue != false { LayoutRootView.setDebugPointer(baseLocation(last, in: window), in: window) }
// Agent input: never the user choosing the app (no-activate guard).
SyntheticInput.register(posted)
Expand All @@ -143,6 +143,7 @@ enum DebugMouse {
/// Top-left window-local point from `x`,`y` or the center of `pane`.
private static func point(_ params: [String: JSONValue], controller: WindowController, window: NSWindow) -> NSPoint? {
if let x = params["x"]?.doubleValue, let y = params["y"]?.doubleValue { return NSPoint(x: x, y: y) }
if let row = params["row"]?.stringValue { return rowPoint(row, controller: controller, window: window) }
guard let key = params["pane"]?.stringValue, let pane = controller.content?.paneController(key: key) else { return nil }
let view: NSView = pane.view.content ?? pane.view
let center = view.convert(NSPoint(x: view.bounds.midX, y: view.bounds.midY), to: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,26 @@ extension WindowController {
return WorkspaceViewSet.half(of: window.convertToScreen(view.convert(view.bounds, to: nil)), at: edge, yDown: false)
}

/// The edge of the workspace on screen under `screenPoint` (screen coordinates) that one sidebar workspace dragged
/// there goes beside: the nearest edge, when the point is within `besideEdgeBand` of the content from it. The drag
/// takes it where the layout has no split edge (`WorkspaceDragResolver.outcome`): Home's pinned dock takes no drop,
/// and a screen edge dock band would merge the workspace into a new dock of the shown one.
func besideEdge(at screenPoint: CGPoint, dragging ids: [String], group: String?) -> TabDropEdge? {
guard SidebarTunables.multiView.value, group == nil, ids.count == 1, let target = content(atScreen: screenPoint),
ids.first != target.workspace.id, let window = target.contentView.window else { return nil }
let view = target.contentView
let rect = window.convertToScreen(view.convert(view.bounds, to: nil))
guard rect.width > 0, rect.height > 0, rect.contains(screenPoint) else { return nil }
// Screen y runs up.
let x = (screenPoint.x - rect.minX) / rect.width, y = (screenPoint.y - rect.minY) / rect.height
let distances: [(edge: TabDropEdge, distance: CGFloat)] = [(.left, x), (.right, 1 - x), (.bottom, y), (.top, 1 - y)]
guard let nearest = distances.min(by: { $0.distance < $1.distance }), nearest.distance <= Self.besideEdgeBand else { return nil }
return nearest.edge
}

/// How far in from an edge of the shown workspace (a fraction of its size) a drop still goes beside it there.
static var besideEdgeBand: CGFloat { 0.25 }

/// A press in a workspace shown beside the focused one focuses it first, so the press lands in focused content.
/// The view the press hits decides, so a sheet, popover or floating pane over it keeps the press.
func focusOnScreenWorkspace(at windowPoint: CGPoint) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import AppKit
import CmuxNextBridge
@testable import CmuxNextApp
@testable import CmuxNextDaemon
import CmuxNextDesign
import CmuxNextHomeEngine
@testable import CmuxNextSidebar
import Foundation
import Testing

/// Multi-view with Home shown (Leo 2026-10-21, sr2-01a1608f-66bb: "you can just drag that workspace into a pane"). Home is
/// a pinned chat dock alone (`pinned-dock-alone-v1`), which takes no tab drop, and its screen has the layout's edge
/// dock bands. A sidebar row dropped on Home's right edge must show that workspace beside Home, and one dropped on
/// Home's bottom edge (in the pane's split zone or in the edge dock band) must stack it under Home, not merge its tabs
/// into a Home dock. Home must be laid out again in its half, not cut off. The #23651 dogfood found the right edge did
/// nothing and the bottom edge cut Home off at the midline.
@MainActor @Suite(.serialized, .enabled(if: !NSScreen.screens.isEmpty, "the drag tests need a display"))
struct SidebarMultiViewHomeTests {
typealias Fixture = SidebarMultiViewWindowTests.Fixture

/// `home` (Home: its page tab in a pinned left chat dock, alone), `k1` and `k2` (a terminal and a browser tab each).
static func tree() -> DaemonTree {
var tree = Fixture.tree(["home", "k1", "k2"])
var page = TabSnapshot(surface: 901, kind: .conversation, title: "Home")
page.conversation = ConversationTabRef(page: HomeDock.page)
let dock = DockSnapshot(edge: .left, mode: .docked, role: .agentChat, isPinned: true)
tree.workspaces[0].screens = [ScreenSnapshot(id: 901, layout: .leaf(901), columns: [ColumnSnapshot(id: 901, width: 0.5, layout: .leaf(901), dock: dock)],
panes: [PaneSnapshot(id: 901, tabs: [page])])]
tree.workspaces[0].kind = "home"
return tree
}

/// The daemon's edge docks (`edge-docks-v1`): the layout has its edge dock bands, as in the app, where a drop
/// near Home's bottom edge is offered a new bottom dock of Home.
static let capabilities = [DaemonCapability.edgeDocks.rawValue]

/// Drags `k1`'s row to `edge` of Home (`inset` of the layout, or `points`, in from it), checks what the hover
/// resolves to, and releases it there.
static func drop(_ f: Fixture, on edge: TabDropEdge, inset: CGFloat = 0.15, points: CGFloat? = nil) throws {
try f.dragRow("k1", to: edge, inset: inset, points: points, release: false)
let hover = f.services.dragSession.drag?.workspaceOutcome
#expect(hover == .window(id: f.state.id, target: .beside(edge)),
"k1 over Home's \(edge) edge (\(points.map { "\($0) pt" } ?? "\(inset)") in) resolves to \(String(describing: hover)): \(f.geometry)")
f.services.dragSession.finish(commit: true)
f.services.dragSession.finishLanding()
f.frame()
}

/// Home's panes lie inside Home's half and fill its height: Home was laid out again, not cut off.
static func expectHomeFits(_ f: Fixture, _ location: SourceLocation = #_sourceLocation) throws {
let home = try #require(f.visible.first { $0.workspace.id == "home" }, sourceLocation: location)
let frame = try #require(f.windowFrame("home"), sourceLocation: location)
let panes = home.panes.values.map { $0.view.convert($0.view.bounds, to: nil) }
try #require(!panes.isEmpty, "Home shows no pane", sourceLocation: location)
for pane in panes {
#expect(frame.insetBy(dx: -1, dy: -1).contains(pane), "a Home pane \(pane) reaches out of Home's half \(frame)", sourceLocation: location)
}
let tallest = panes.map(\.height).max() ?? 0
#expect(tallest >= frame.height * 0.8, "Home's panes (\(panes)) do not fill its half \(frame)", sourceLocation: location)
}

@Test func aRowDroppedOnHomesRightEdgeShowsItsWorkspaceBesideHome() async throws {
try await SidebarMultiViewWindowTests.withMultiView {
let f = try await Fixture(keys: ["home", "k1", "k2"], tree: Self.tree(), capabilities: Self.capabilities)
defer { f.close() }
try #require(f.shown == ["home"], "the window shows \(f.shown)")
let k1Tabs = f.tabs("k1")

try Self.drop(f, on: .right)
#expect(f.shown == ["home", "k1"], "the right-edge drop on Home did not show k1 beside it: \(f.shown) \(f.geometry)")
#expect(f.onScreen == ["home", "k1"], "the sidebar lights \(f.onScreen)")
#expect(f.state.workspaceID == "home", "the drop switched the focused workspace")
#expect(f.tabs("k1") == k1Tabs, "the drop moved k1's tabs: it merged into Home instead of placing beside")
let left = try #require(f.windowFrame("home")), right = try #require(f.windowFrame("k1"))
#expect(right.minX >= left.maxX, "k1 is not on Home's right: \(left) \(right)")
try Self.expectHomeFits(f)
}
}

/// The bottom edge, in the pane's split zone (15% in, `nil`) and in the screen's bottom dock band (8 pt in: the
/// band is `LayoutStyle.dockDropBand`, 24 pt, where the layout offers a new bottom dock of Home).
@Test(arguments: [nil, 8.0] as [Double?])
func aRowDroppedOnHomesBottomEdgeStacksItUnderHome(points: Double?) async throws {
try await SidebarMultiViewWindowTests.withMultiView {
let f = try await Fixture(keys: ["home", "k1", "k2"], tree: Self.tree(), capabilities: Self.capabilities)
defer { f.close() }
try #require(f.shown == ["home"], "the window shows \(f.shown)")
try #require(f.controller.content?.layoutModel.acceptsEdgeDockDrops == true, "the layout has no edge dock bands")
let k1Tabs = f.tabs("k1")

try Self.drop(f, on: .bottom, points: points.map { CGFloat($0) })
#expect(f.shown == ["home", "k1"], "the bottom-edge drop on Home did not stack k1 under it: \(f.shown) \(f.geometry)")
#expect(f.tabs("k1") == k1Tabs, "the drop moved k1's tabs: it merged into Home instead of placing beside")
#expect(f.services.workspace(id: "k1") != nil, "k1 closed: its tabs merged into Home")
#expect(f.state.workspaceID == "home")
let top = try #require(f.windowFrame("home")), bottom = try #require(f.windowFrame("k1"))
#expect(bottom.maxY <= top.minY, "k1 is not under Home: \(top) \(bottom)") // window coordinates, y up
try Self.expectHomeFits(f)
}
}
}
Loading
Loading