// between viewports.
"split-horizontal": splitLayoutAvailable(),
"split-vertical": splitLayoutAvailable(),
+ // Zoom fills the grid with the focused pane, so it needs panes to
+ // hide — with one pane there is nothing to zoom.
+ zoom: splitLayoutActive(),
"close-split": splitLayoutActive(),
restart: !!sel && terminal && !archived,
archive: isUserSession,
@@ -8524,6 +8529,11 @@
const on = enabledByAction[btn.dataset.menuAction];
btn.disabled = on === false;
}
+ // Unlike the TUI — whose zoomed layout is borderless, with no title bar
+ // to reopen the menu from — a zoomed pane here keeps its head, so the
+ // row is the way back out and has to say so.
+ const zoomItem = $("sessionMenuZoomBtn");
+ if (zoomItem) zoomItem.textContent = state.paneZoom ? "unzoom" : "zoom";
const pinItem = $("sessionMenuPinItem");
if (pinItem) pinItem.textContent = pinned ? "unpin" : "pin";
const archiveItem = $("sessionMenuArchiveItem");
@@ -14514,6 +14524,9 @@
if (!leaves.some((l) => l.id === state.focusedPaneId)) {
state.focusedPaneId = leaves.length ? leaves[0].id : null;
}
+ // A collapsed layout has nothing to zoom. Drop the per-client flag so the
+ // next split doesn't come back pre-zoomed with no visible way out.
+ if (leaves.length <= 1) state.paneZoom = false;
renderPaneGrid();
pushLayout(next);
}
@@ -19302,6 +19315,14 @@
$("sessionMenuBtn").setAttribute("aria-expanded", open ? "true" : "false");
});
+/** Fill the pane grid with the focused pane, or restore the split layout.
+ * Shared by `C-x z` and the session menu's zoom row. Per-client state
+ * (spec 0118), so it never round-trips to the daemon. */
+function togglePaneZoom() {
+ state.paneZoom = !state.paneZoom;
+ renderPaneGrid();
+}
+
$("sessionMenu").addEventListener("click", (ev) => {
const btn = ev.target.closest("[data-menu-action]");
if (!btn || btn.disabled) return;
@@ -19311,6 +19332,7 @@
// through the session-action path.
if (action === "split-horizontal") return splitFocusedPane("right");
if (action === "split-vertical") return splitFocusedPane("below");
+ if (action === "zoom") return togglePaneZoom();
if (action === "close-split") return closePane(state.focusedPaneId);
handleRowAction(action, state.currentId);
});
@@ -20252,8 +20274,7 @@
showChordEcho("C-x z — nothing to zoom", true);
return;
}
- state.paneZoom = !state.paneZoom;
- renderPaneGrid();
+ togglePaneZoom();
return;
case "new-session":
openNewSessionDialog();
diff --git a/crates/e2e/tests/split_layout.rs b/crates/e2e/tests/split_layout.rs
index 10969eff..d7dddd86 100644
--- a/crates/e2e/tests/split_layout.rs
+++ b/crates/e2e/tests/split_layout.rs
@@ -575,6 +575,8 @@ async fn shared_split_layout_renders_wide_and_is_read_only_narrow() {
"the session menu pill belongs in the pane title bar once one exists"
);
+ let before_close_baseline = d.client.layout().await.expect("layout").version;
+
// ...and that menu is what now offers the split/close actions.
let menu_actions: String = page
.evaluate(
@@ -586,16 +588,63 @@ async fn shared_split_layout_renders_wide_and_is_read_only_narrow() {
.ok()
.and_then(|r| r.into_value::().ok())
.unwrap_or_default();
- for action in ["split-horizontal", "split-vertical", "close-split"] {
+ for action in ["split-horizontal", "split-vertical", "zoom", "close-split"] {
assert!(
menu_actions.contains(action),
"session menu must offer {action}, got {menu_actions}"
);
}
+ // Zoom fills the grid with the focused pane and — unlike the TUI, whose
+ // zoomed layout is borderless — the pane head survives, so the same row
+ // is the way back out and must relabel itself.
+ assert_eq!(
+ zoom_menu_label(&page).await,
+ "zoom",
+ "an unzoomed split offers zoom"
+ );
+ page.evaluate(
+ "(() => { document.getElementById('sessionMenuZoomBtn').click(); return true; })()",
+ )
+ .await
+ .ok();
+ assert!(
+ wait_for_bool(
+ &page,
+ "document.getElementById('paneGrid').classList.contains('is-zoomed')",
+ )
+ .await,
+ "the menu's zoom row must zoom the pane grid"
+ );
+ // Zoom is per-client (spec 0118): it hides panes, it does not rewrite the
+ // shared tree, so no layout edit is published.
+ let after_zoom = d.client.layout().await.expect("layout");
+ assert_eq!(
+ after_zoom.version, before_close_baseline,
+ "zoom is client-local and must not publish a layout edit"
+ );
+ assert_eq!(
+ zoom_menu_label(&page).await,
+ "unzoom",
+ "a zoomed pane's menu is the way back out"
+ );
+ page.evaluate(
+ "(() => { document.getElementById('sessionMenuZoomBtn').click(); return true; })()",
+ )
+ .await
+ .ok();
+ assert!(
+ wait_for_bool(
+ &page,
+ "!document.getElementById('paneGrid').classList.contains('is-zoomed')",
+ )
+ .await,
+ "clicking unzoom must restore the split layout"
+ );
+
// Closing a split through the menu really collapses the layout, and the
// collapse is published like any other layout edit.
- let before_close = d.client.layout().await.expect("layout").version;
+ let before_close = before_close_baseline;
page.evaluate(
"(() => { document.querySelector('#sessionMenu [data-menu-action=\"close-split\"]').click(); return true; })()",
)
@@ -1015,6 +1064,21 @@ async fn set_viewport(page: &Page, (w, h): (u32, u32)) {
tokio::time::sleep(Duration::from_millis(350)).await;
}
+/// Open the session menu (so its items refresh against current state) and
+/// read what the zoom row currently offers: "zoom" or "unzoom".
+async fn zoom_menu_label(page: &Page) -> String {
+ page.evaluate(
+ "(() => {
+ document.getElementById('sessionMenuBtn').click();
+ return document.getElementById('sessionMenuZoomBtn').textContent.trim();
+ })()",
+ )
+ .await
+ .ok()
+ .and_then(|r| r.into_value::().ok())
+ .unwrap_or_default()
+}
+
async fn eval_number(page: &Page, js: &str) -> f64 {
page.evaluate(js)
.await
diff --git a/specs/0145-webui-session-controls.md b/specs/0145-webui-session-controls.md
index a8ce8261..54aedb8f 100644
--- a/specs/0145-webui-session-controls.md
+++ b/specs/0145-webui-session-controls.md
@@ -19,8 +19,11 @@ Scope: Where the web UI's per-session controls live and how session reorder work
of the session view (next to the terminal scroll controls), mirroring
the TUI's session-title menu: rename, pin/unpin, fork conversation,
restart, archive/unarchive, merge and archive (enabled only for forks,
- visible otherwise so the menu teaches the workflow), delete. TUI-only
- entries that manage split panes are omitted — the web UI has no splits.
+ visible otherwise so the menu teaches the workflow), delete. It also
+ carries the pane actions for the web UI's own split layout — split
+ horizontal, split vertical, zoom/unzoom, close split — shown but
+ disabled when the viewport or the current layout cannot offer them, so
+ the menu keeps a stable shape.
- Client-side preferences (currently the theme) live in a settings sheet
opened by activating the matrix-rain connection badge in the header,
which doubles as the settings button.
@@ -47,4 +50,3 @@ single menu matching the TUI keep both clients teaching the same model.
- Cross-region drops (e.g. dragging into a different project group) are
not reorder semantics; grouping stays a separate operation.
-- The TUI's split-pane management stays TUI-only.