Release v1.36.0 (#47)

* Fix multi-workspace drag/drop, WebView click focus, and add regression tests

- Wire bonsplit isInteractive to workspace active state so inactive
  workspace NSViews are hidden from AppKit event routing
- Add CmuxWebView.mouseDown notification for browser panel focus
  tracking (AppKit delivers clicks to WKWebView, not SwiftUI overlays)
- Add multi-workspace focus regression test covering isHidden fix,
  rapid workspace switching, and browser panel focus routing

* Bump version to 1.36.0
This commit is contained in:
Lawrence Chen 2026-02-17 04:04:29 -08:00 committed by GitHub
parent 2678606a20
commit c2fdd48290
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 433 additions and 13 deletions

View file

@ -2,6 +2,13 @@
All notable changes to cmux are documented here.
## [1.36.0] - 2026-02-17
### Fixed
- App hang when omnibar safety timeout failed to fire (blocked main thread)
- Tab drag/drop not working when multiple workspaces exist
- Clicking in browser WebView not focusing the browser tab
## [1.35.0] - 2026-02-17
### Fixed

View file

@ -682,7 +682,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -691,7 +691,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.35.0;
MARKETING_VERSION = 1.36.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -721,7 +721,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -730,7 +730,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.35.0;
MARKETING_VERSION = 1.36.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -784,10 +784,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.35.0;
MARKETING_VERSION = 1.36.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -801,10 +801,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.35.0;
MARKETING_VERSION = 1.36.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -818,10 +818,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.35.0;
MARKETING_VERSION = 1.36.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -837,10 +837,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.35.0;
MARKETING_VERSION = 1.36.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -99,6 +99,13 @@ struct BrowserPanelView: View {
.onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in
omnibarPillFrame = frame
}
.onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in
// Only handle clicks from our own webview.
guard let webView = note.object as? CmuxWebView else { return false }
return webView === panel?.webView
}) { _ in
onRequestPanelFocus()
}
.onAppear {
UserDefaults.standard.register(defaults: [
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,

View file

@ -32,6 +32,17 @@ final class CmuxWebView: WKWebView {
super.keyDown(with: event)
}
// MARK: - Focus on click
// The SwiftUI Color.clear overlay (.onTapGesture) that focuses panes can't receive
// clicks when a WKWebView is underneath AppKit delivers the click to the deepest
// NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so
// bonsplit focus tracks which pane the user clicked in.
override func mouseDown(with event: NSEvent) {
NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self)
super.mouseDown(with: event)
}
// MARK: - Drag-and-drop passthrough
// WKWebView inherently calls registerForDraggedTypes with public.text (and others).

View file

@ -2227,4 +2227,5 @@ extension Notification.Name {
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
}

View file

@ -14,6 +14,10 @@ struct WorkspaceContentView: View {
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
workspace.panels.count > 1
// Inactive workspaces are kept alive in a ZStack (for state preservation) but their
// AppKit-backed views can still intercept drags. Disable drop acceptance for them.
let _ = { workspace.bonsplitController.isInteractive = isTabActive }()
BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
// Content for each tab in bonsplit
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)

View file

@ -5,6 +5,13 @@ description: Release notes and version history for cmux
All notable changes to cmux are documented here.
## [1.36.0] - 2026-02-17
### Fixed
- App hang when omnibar safety timeout failed to fire (blocked main thread)
- Tab drag/drop not working when multiple workspaces exist
- Clicking in browser WebView not focusing the browser tab
## [1.35.0] - 2026-02-17
### Fixed

View file

@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""
Regression test: multi-workspace terminal and browser focus.
Bug 1 (isHidden): When multiple workspaces exist in a ZStack, inactive workspaces'
AppKit NSViews (NSSplitView, NSHostingController containers) remain in the window's
view hierarchy and intercept events (drags, clicks) even when SwiftUI sets
.allowsHitTesting(false). Fix: set isHidden=true on NSView containers for inactive
workspaces via bonsplit's isInteractive flag.
Bug 2 (webview click focus): Clicking inside a WKWebView didn't focus the browser
tab because AppKit delivers the click to WKWebView, not to the SwiftUI Color.clear
overlay used for focus tracking. Fix: CmuxWebView.mouseDown posts a notification
that BrowserPanelView listens for to call onRequestPanelFocus().
This test validates:
1) Terminals in non-active workspaces remain responsive after switching back.
2) Terminals in workspaces with splits remain responsive after cycling through
multiple workspaces (the isHidden toggle doesn't break view attachment).
3) Browser panel can receive focus and the terminal can reclaim focus afterward.
"""
import os
import sys
import time
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
MARKER_DIR = Path(tempfile.gettempdir())
def _marker(name: str) -> Path:
return MARKER_DIR / f"cmux_mwf_{name}_{os.getpid()}"
def _clear(marker: Path):
marker.unlink(missing_ok=True)
def _wait_marker(marker: Path, timeout: float = 5.0) -> bool:
start = time.time()
while time.time() - start < timeout:
if marker.exists():
return True
time.sleep(0.1)
return False
def _verify_responsive(c: cmux, marker: Path, surface_idx: int, retries: int = 3) -> bool:
"""Send a touch command to a specific terminal surface and check the marker appears."""
for attempt in range(retries):
_clear(marker)
try:
c.send_key_surface(surface_idx, "ctrl-c")
except Exception:
time.sleep(0.5)
continue
time.sleep(0.3)
try:
c.send_surface(surface_idx, f"touch {marker}\n")
except Exception:
time.sleep(0.5)
continue
if _wait_marker(marker, timeout=3.0):
return True
time.sleep(0.5)
return False
def _wait_terminal_in_window(c: cmux, surface_idx: int, timeout: float = 5.0) -> bool:
start = time.time()
while time.time() - start < timeout:
try:
health = c.surface_health()
except Exception:
health = []
for h in health:
if h.get("index") == surface_idx and h.get("type") == "terminal" and h.get("in_window"):
return True
time.sleep(0.2)
return False
def test_multi_workspace_terminal_responsive(c: cmux) -> None:
"""
Create two workspaces with splits, cycle between them, and verify all terminals
in each workspace remain responsive. Before the isHidden fix, terminals in
workspace A would lose input when workspace B's NSViews sat on top in the
view hierarchy.
"""
# Workspace A
ws_a = c.new_workspace()
time.sleep(0.3)
c.new_split("right")
time.sleep(0.8)
_wait_terminal_in_window(c, 0, timeout=5.0)
_wait_terminal_in_window(c, 1, timeout=5.0)
# Workspace B
ws_b = c.new_workspace()
time.sleep(0.3)
c.new_split("right")
time.sleep(0.8)
_wait_terminal_in_window(c, 0, timeout=5.0)
_wait_terminal_in_window(c, 1, timeout=5.0)
# Verify workspace B terminals work (this is the "last" workspace)
m_b0 = _marker("wsb_0")
m_b1 = _marker("wsb_1")
try:
assert _verify_responsive(c, m_b0, 0), "Workspace B surface 0 not responsive"
assert _verify_responsive(c, m_b1, 1), "Workspace B surface 1 not responsive"
finally:
_clear(m_b0)
_clear(m_b1)
# Switch back to workspace A
c.select_workspace(ws_a)
time.sleep(0.5)
_wait_terminal_in_window(c, 0, timeout=5.0)
_wait_terminal_in_window(c, 1, timeout=5.0)
# Verify workspace A terminals work (this was the bug: non-last workspace lost input)
m_a0 = _marker("wsa_0")
m_a1 = _marker("wsa_1")
try:
assert _verify_responsive(c, m_a0, 0), \
"Workspace A surface 0 not responsive after switching back (isHidden regression)"
assert _verify_responsive(c, m_a1, 1), \
"Workspace A surface 1 not responsive after switching back (isHidden regression)"
finally:
_clear(m_a0)
_clear(m_a1)
# Cycle back to B and verify
c.select_workspace(ws_b)
time.sleep(0.5)
_wait_terminal_in_window(c, 0, timeout=5.0)
m_b0_2 = _marker("wsb_0_2")
try:
assert _verify_responsive(c, m_b0_2, 0), \
"Workspace B surface 0 not responsive after cycling"
finally:
_clear(m_b0_2)
# Cleanup
c.close_workspace(ws_b)
time.sleep(0.3)
c.close_workspace(ws_a)
time.sleep(0.3)
def test_three_workspaces_non_last_responsive(c: cmux) -> None:
"""
Three workspaces: verify the FIRST workspace (furthest back in ZStack) is still
responsive. This is the worst case for the old bug since it has two inactive
workspaces' NSViews stacked above it.
"""
ws_a = c.new_workspace()
time.sleep(0.3)
c.new_split("down")
time.sleep(0.8)
ws_b = c.new_workspace()
time.sleep(0.3)
ws_c = c.new_workspace()
time.sleep(0.3)
# Switch back to workspace A (furthest back in ZStack)
c.select_workspace(ws_a)
time.sleep(0.5)
_wait_terminal_in_window(c, 0, timeout=5.0)
_wait_terminal_in_window(c, 1, timeout=5.0)
m0 = _marker("3ws_0")
m1 = _marker("3ws_1")
try:
assert _verify_responsive(c, m0, 0), \
"First workspace surface 0 not responsive with 2 workspaces stacked above"
assert _verify_responsive(c, m1, 1), \
"First workspace surface 1 not responsive with 2 workspaces stacked above"
finally:
_clear(m0)
_clear(m1)
# Cleanup
c.close_workspace(ws_c)
time.sleep(0.2)
c.close_workspace(ws_b)
time.sleep(0.2)
c.close_workspace(ws_a)
time.sleep(0.2)
def test_rapid_workspace_switching_preserves_focus(c: cmux) -> None:
"""
Rapidly switch between workspaces and verify terminals remain responsive.
The isHidden toggle must not break view attachment or cause blank terminals.
"""
ws_a = c.new_workspace()
time.sleep(0.3)
c.new_split("right")
time.sleep(0.8)
ws_b = c.new_workspace()
time.sleep(0.3)
# Rapid switching
for _ in range(5):
c.select_workspace(ws_a)
time.sleep(0.15)
c.select_workspace(ws_b)
time.sleep(0.15)
# Settle on workspace A and verify
c.select_workspace(ws_a)
time.sleep(0.5)
_wait_terminal_in_window(c, 0, timeout=5.0)
_wait_terminal_in_window(c, 1, timeout=5.0)
m0 = _marker("rapid_0")
m1 = _marker("rapid_1")
try:
assert _verify_responsive(c, m0, 0), \
"Surface 0 not responsive after rapid workspace switching"
assert _verify_responsive(c, m1, 1), \
"Surface 1 not responsive after rapid workspace switching"
finally:
_clear(m0)
_clear(m1)
# Cleanup
c.close_workspace(ws_b)
time.sleep(0.2)
c.close_workspace(ws_a)
time.sleep(0.2)
def test_browser_panel_focus_and_return(c: cmux) -> None:
"""
Create a terminal and a browser surface in the same pane, focus the browser,
then switch back to the terminal. Verifies focus routing works correctly for
browser panels.
"""
ws = c.new_workspace()
time.sleep(0.3)
# Get the terminal panel ID
surfaces = c.list_pane_surfaces()
if not surfaces:
raise cmuxError("No surfaces after new_workspace")
term_panel_id = surfaces[0][1]
# Create a browser surface in the same pane
browser_panel_id = c.new_surface(panel_type="browser", url="about:blank")
time.sleep(0.5)
# Focus the browser and verify
c.focus_webview(browser_panel_id)
time.sleep(0.3)
assert c.is_webview_focused(browser_panel_id), \
"Browser panel should have focus after focus_webview"
# Switch back to terminal and verify it's responsive
c.focus_surface_by_panel(term_panel_id)
time.sleep(0.3)
m = _marker("browser_return")
try:
# Use the focused terminal
_clear(m)
c.send_key("ctrl-c")
time.sleep(0.2)
c.send(f"touch {m}\n")
assert _wait_marker(m, timeout=3.0), \
"Terminal not responsive after switching back from browser"
finally:
_clear(m)
# Cleanup
c.close_workspace(ws)
time.sleep(0.2)
def test_browser_focus_across_workspaces(c: cmux) -> None:
"""
Workspace A has a terminal, workspace B has a browser. Switching between them
should correctly route focus to each panel type.
"""
ws_a = c.new_workspace()
time.sleep(0.3)
ws_b = c.new_workspace()
time.sleep(0.3)
# Create a browser in workspace B
browser_panel_id = c.new_surface(panel_type="browser", url="about:blank")
time.sleep(0.5)
# Focus browser in workspace B
c.focus_webview(browser_panel_id)
time.sleep(0.3)
assert c.is_webview_focused(browser_panel_id), \
"Browser should have focus in workspace B"
# Switch to workspace A (terminal)
c.select_workspace(ws_a)
time.sleep(0.5)
_wait_terminal_in_window(c, 0, timeout=5.0)
m = _marker("cross_ws_term")
try:
assert _verify_responsive(c, m, 0), \
"Terminal in workspace A not responsive after switching from browser workspace"
finally:
_clear(m)
# Switch back to workspace B and verify browser still works
c.select_workspace(ws_b)
time.sleep(0.5)
c.focus_webview(browser_panel_id)
time.sleep(0.3)
assert c.is_webview_focused(browser_panel_id), \
"Browser should regain focus after switching back to workspace B"
# Cleanup
c.close_workspace(ws_b)
time.sleep(0.2)
c.close_workspace(ws_a)
time.sleep(0.2)
def main() -> int:
print("=" * 60)
print("Multi-Workspace Focus Regression Tests")
print("=" * 60)
print()
tests = [
("Multi-workspace terminal responsive (isHidden regression)", test_multi_workspace_terminal_responsive),
("Three workspaces non-last responsive", test_three_workspaces_non_last_responsive),
("Rapid workspace switching preserves focus", test_rapid_workspace_switching_preserves_focus),
("Browser panel focus and return", test_browser_panel_focus_and_return),
("Browser focus across workspaces", test_browser_focus_across_workspaces),
]
with cmux() as c:
c.activate_app()
time.sleep(0.2)
passed = 0
failed = 0
for name, test_fn in tests:
print(f" {name}...", end=" ", flush=True)
try:
test_fn(c)
print("PASS")
passed += 1
except (AssertionError, cmuxError) as e:
print(f"FAIL: {e}")
failed += 1
except Exception as e:
print(f"ERROR: {type(e).__name__}: {e}")
failed += 1
print()
print(f"Results: {passed} passed, {failed} failed out of {passed + failed}")
if failed == 0:
print("\nPASS: multi-workspace focus")
return 0
else:
print(f"\nFAIL: {failed} test(s) failed")
return 1
if __name__ == "__main__":
raise SystemExit(main())

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit d8af81190c90a0ddb28f8dbd5ad79070564b2234
Subproject commit 1d41e7b9ecfd310e6d780c2d93e74a34d759edc8