diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index 38a12d5a..a8deca51 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -368,6 +368,13 @@ main.session-list-hidden { grid-template-columns: 0 1fr; } + /* Zoom (spec 0212) means the focused session view fills the window, so the + list gets out of the way — the same geometry as hiding it, but driven by + zoom rather than by the user's own show/hide preference, which is left + untouched so unzooming restores exactly what they had. */ + main.is-zoomed { + grid-template-columns: 0 1fr; + } @media (max-width: 720px) { main, main.session-list-hidden { @@ -387,6 +394,18 @@ main:has(.session-list.collapsed) { grid-template-rows: 0 1fr; } + main.is-zoomed { + grid-template-rows: 0 1fr; + } + main.is-zoomed .session-list-wrap { + min-height: 0; + overflow: hidden; + border-right: none; + border-bottom: none; + } + main.is-zoomed .new-session-btn { + display: none; + } .session-list.collapsed { max-height: 0; padding: 0; border-bottom: none; } /* When the list is collapsed, drop its header controls too — they act on the list, and there's no list visible. */ @@ -2387,7 +2406,8 @@ border-right: 1px solid var(--border); background: var(--bg-elev); } - main.session-list-hidden .session-list-wrap { + main.session-list-hidden .session-list-wrap, + main.is-zoomed .session-list-wrap { overflow: hidden; border-right: none; } @@ -2415,7 +2435,8 @@ .session-list-resizer.is-dragging::after { background: var(--accent); } - main.session-list-hidden .session-list-resizer { + main.session-list-hidden .session-list-resizer, + main.is-zoomed .session-list-resizer { display: none; } @media (max-width: 720px) { @@ -8516,9 +8537,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(), + // Zoom fills the window with the focused view, which is meaningful + // with or without splits — there is always a list to cover. + zoom: !!sel, "close-split": splitLayoutActive(), restart: !!sel && terminal && !archived, archive: isUserSession, @@ -14139,6 +14160,11 @@

function setSessionListVisible(visible, persist = true) { state.sessionListVisible = !!visible; + // Zoom hides the list, so asking for the list is asking to leave zoom — + // otherwise the `≡` toggle and `C-x b` would both be dead controls while + // zoomed. (The TUI answers the same request by zooming the list instead; + // there is no zoomed-list surface here.) + if (visible && state.paneZoom) setPaneZoom(false); // Collapsing/expanding the list reshapes the terminal host, which can // clamp the xterm viewport's scrollTop. Stamp the churn window BEFORE // the class flip so the resulting scroll events are never mistaken for @@ -14151,12 +14177,20 @@

sessionListEl.classList.remove("collapsed"); if (visible) setSessionListWidth(currentSessionListWidth(), false); } - toggleListBtn.setAttribute("aria-expanded", visible ? "true" : "false"); - toggleListBtn.title = visible ? "hide session list" : "show session list"; + syncSessionListToggle(); if (persist) localStorage.setItem(SESSION_LIST_VISIBLE_KEY, visible ? "1" : "0"); if (state.mode === "terminal") requestAnimationFrame(refitTerminal); } +/** The `≡` button reports what a press would do. Zoom hides the list without + * touching the preference, so while zoomed the button offers the list back + * (by unzooming) even though the preference still says "visible". */ +function syncSessionListToggle() { + const shown = state.sessionListVisible && !state.paneZoom; + toggleListBtn.setAttribute("aria-expanded", shown ? "true" : "false"); + toggleListBtn.title = shown ? "hide session list" : "show session list"; +} + function isSessionListVisible() { return state.sessionListVisible; } @@ -14176,6 +14210,9 @@

initSessionListLayout(); toggleListBtn.addEventListener("click", () => { + // While zoomed the list is hidden by zoom, not by the preference, so a + // press means "give me the list back" — which unzooms. + if (state.paneZoom) return setSessionListVisible(true); setSessionListVisible(!isSessionListVisible()); }); @@ -14524,9 +14561,6 @@

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); } @@ -19315,12 +19349,28 @@

$("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. */ +/** Zoom, or restore. 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; + setPaneZoom(!state.paneZoom); +} + +/** Zoom means the focused session view fills the window (spec 0212): the + * other panes get out of the way, and so does the session list. The list's + * own show/hide preference is deliberately not touched — unzooming restores + * whatever the user had, rather than whatever zoom left behind. */ +function setPaneZoom(on) { + const next = !!on; + if (state.paneZoom === next) return; + state.paneZoom = next; + // Reshaping the view can clamp xterm's scrollTop. Stamp the churn window + // BEFORE the layout flips so the resulting scroll events are never mistaken + // for the user scrolling into history — same order as the list toggle. + noteTerminalScrollChurn(); + mainEl.classList.toggle("is-zoomed", next); + syncSessionListToggle(); renderPaneGrid(); + if (state.mode === "terminal") requestAnimationFrame(refitTerminal); } $("sessionMenu").addEventListener("click", (ev) => { @@ -20270,7 +20320,7 @@

return; } case "toggle-zoom": - if (!splitLayoutActive()) { + if (!currentSession()) { showChordEcho("C-x z — nothing to zoom", true); return; } diff --git a/crates/e2e/tests/split_layout.rs b/crates/e2e/tests/split_layout.rs index d7dddd86..2d3a36ba 100644 --- a/crates/e2e/tests/split_layout.rs +++ b/crates/e2e/tests/split_layout.rs @@ -616,6 +616,16 @@ async fn shared_split_layout_renders_wide_and_is_read_only_narrow() { .await, "the menu's zoom row must zoom the pane grid" ); + // Spec 0212: zoom fills the window, so the session list gets out of the + // way too — not just the sibling panes. + assert!( + wait_for_bool( + &page, + "document.querySelector('.session-list-wrap').getBoundingClientRect().width === 0", + ) + .await, + "zoom must cover the session list" + ); // 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"); @@ -641,6 +651,16 @@ async fn shared_split_layout_renders_wide_and_is_read_only_narrow() { .await, "clicking unzoom must restore the split layout" ); + // ...list included: zoom borrows the list, it does not consume the + // user's own show/hide preference. + assert!( + wait_for_bool( + &page, + "document.querySelector('.session-list-wrap').getBoundingClientRect().width > 0", + ) + .await, + "unzooming must give the session list back" + ); // Closing a split through the menu really collapses the layout, and the // collapse is published like any other layout edit. @@ -662,6 +682,50 @@ async fn shared_split_layout_renders_wide_and_is_read_only_narrow() { "closing a pane is a layout edit and must be published to other clients" ); + // Zoom is not split-gated (spec 0212): with one pane it still has the + // list to cover, so the row stays live and does the same thing. + let zoom_enabled_single = wait_for_bool( + &page, + "(() => { + const btn = document.getElementById('sessionMenuBtn'); + const item = document.getElementById('sessionMenuZoomBtn'); + if (!btn || !item) return false; + if (document.getElementById('sessionMenu').hidden) btn.click(); + return !item.disabled; + })()", + ) + .await; + assert!( + zoom_enabled_single, + "zoom must stay available when the layout has a single pane" + ); + page.evaluate( + "(() => { document.getElementById('sessionMenuZoomBtn').click(); return true; })()", + ) + .await + .ok(); + assert!( + wait_for_bool( + &page, + "document.querySelector('.session-list-wrap').getBoundingClientRect().width === 0", + ) + .await, + "zooming a single pane must still cover the session list" + ); + // The `≡` toggle asks for the list back, which is a request to leave zoom + // — it must not be a dead control while zoomed. + page.evaluate("(() => { document.getElementById('toggleList').click(); return true; })()") + .await + .ok(); + assert!( + wait_for_bool( + &page, + "document.querySelector('.session-list-wrap').getBoundingClientRect().width > 0", + ) + .await, + "asking for the list while zoomed must leave zoom" + ); + // Split back from a SINGLE pane through the session menu. This is the // only path to a first split now that the main title bar carries no // split buttons — with one pane there is no pane title bar to hold diff --git a/specs/0212-zoom-fills-the-client-window.md b/specs/0212-zoom-fills-the-client-window.md new file mode 100644 index 00000000..265c53b0 --- /dev/null +++ b/specs/0212-zoom-fills-the-client-window.md @@ -0,0 +1,56 @@ +# 0212-zoom-fills-the-client-window + +Status: accepted +Date: 2026-08-28 +Area: ux +Scope: What zoom means in a client, and what it is allowed to hide. + +## Decision + +Zoom means: **the focused session view fills this client's window.** Every +other pane gets out of the way, and so does the session list. + +- Zoom is available whether or not a split layout exists. With one pane it + still has the list to cover, so it is never a no-op the user has to + discover by pressing it. +- Zoom is per-client presentation state (see + [0118-split-layout-is-shared-daemon-state](0118-split-layout-is-shared-daemon-state.md)). + It hides panes rather than rewriting the shared tree, publishes no layout + edit, and unzooming restores the layout exactly. +- Zoom must not consume the user's own list show/hide preference. It hides + the list for the duration; unzooming restores whatever the user had, not + whatever zoom left behind. +- Asking for the session list while zoomed is a request to leave zoom. A + client must not leave a control that asks for the list doing nothing. +- Whatever surface offers zoom must also offer the way back, labelled as + such, unless the zoomed layout genuinely has no room for it — a client + whose zoomed view keeps its title bar has room. + +## Reason + +"Zoom" that only hid sibling panes left the session list occupying a +column, so the same gesture produced a full-screen view in one client and a +partial one in another, and did nothing at all in the common single-pane +case. One name should mean one thing across clients. + +Zoom is a viewing gesture, not a layout edit: the user is looking closer, +not rearranging what everyone else sees. That is why it stays client-local +and why it must be exactly reversible — including the preferences it +temporarily overrides. + +## Consequences + +- New chrome that competes for space (list, sidebars, panels) must decide + whether zoom hides it; the default answer is yes, since zoom's promise is + the window. +- Clients must keep zoom out of persisted preferences, or they will leak a + temporary view state into the user's saved layout. +- A client whose zoomed layout drops its title bar has no menu to offer the + way out from; its zoom must stay reachable by key or command. + +## Non-Goals + +- Zoom is not a layout edit and never becomes one; a user who wants other + clients to see one pane closes the others instead. +- Hiding the composer, header, or other input affordances is not implied — + zoom covers what competes with the view, not what drives it.