Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 23 additions & 2 deletions crates/daemon/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4160,6 +4160,8 @@
id="sessionMenuSplitHBtn">split horizontal</button>
<button type="button" role="menuitem" data-menu-action="split-vertical"
id="sessionMenuSplitVBtn">split vertical</button>
<button type="button" role="menuitem" data-menu-action="zoom"
id="sessionMenuZoomBtn">zoom</button>
<button type="button" role="menuitem" data-menu-action="close-split"
id="sessionMenuCloseSplitBtn">close split</button>
<button type="button" role="menuitem" data-menu-action="restart">restart</button>
Expand Down Expand Up @@ -8514,6 +8516,9 @@ <h2 id="operatorViewTitle"></h2>
// 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,
Expand All @@ -8524,6 +8529,11 @@ <h2 id="operatorViewTitle"></h2>
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");
Expand Down Expand Up @@ -14514,6 +14524,9 @@ <h2 id="operatorViewTitle"></h2>
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);
}
Expand Down Expand Up @@ -19302,6 +19315,14 @@ <h2 id="operatorViewTitle"></h2>
$("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;
Expand All @@ -19311,6 +19332,7 @@ <h2 id="operatorViewTitle"></h2>
// 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);
});
Expand Down Expand Up @@ -20252,8 +20274,7 @@ <h2 id="operatorViewTitle"></h2>
showChordEcho("C-x z — nothing to zoom", true);
return;
}
state.paneZoom = !state.paneZoom;
renderPaneGrid();
togglePaneZoom();
return;
case "new-session":
openNewSessionDialog();
Expand Down
68 changes: 66 additions & 2 deletions crates/e2e/tests/split_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -586,16 +588,63 @@ async fn shared_split_layout_renders_wide_and_is_read_only_narrow() {
.ok()
.and_then(|r| r.into_value::<String>().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; })()",
)
Expand Down Expand Up @@ -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::<String>().ok())
.unwrap_or_default()
}

async fn eval_number(page: &Page, js: &str) -> f64 {
page.evaluate(js)
.await
Expand Down
8 changes: 5 additions & 3 deletions specs/0145-webui-session-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Loading