diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index cbb02acc..38a12d5a 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -4160,6 +4160,8 @@ id="sessionMenuSplitHBtn">split horizontal + @@ -8514,6 +8516,9 @@

// 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.