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
80 changes: 65 additions & 15 deletions crates/daemon/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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. */
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -8516,9 +8537,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(),
// 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,
Expand Down Expand Up @@ -14139,6 +14160,11 @@ <h2 id="operatorViewTitle"></h2>

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
Expand All @@ -14151,12 +14177,20 @@ <h2 id="operatorViewTitle"></h2>
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;
}
Expand All @@ -14176,6 +14210,9 @@ <h2 id="operatorViewTitle"></h2>
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());
});

Expand Down Expand Up @@ -14524,9 +14561,6 @@ <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 @@ -19315,12 +19349,28 @@ <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. */
/** 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) => {
Expand Down Expand Up @@ -20270,7 +20320,7 @@ <h2 id="operatorViewTitle"></h2>
return;
}
case "toggle-zoom":
if (!splitLayoutActive()) {
if (!currentSession()) {
showChordEcho("C-x z — nothing to zoom", true);
return;
}
Expand Down
64 changes: 64 additions & 0 deletions crates/e2e/tests/split_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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.
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions specs/0212-zoom-fills-the-client-window.md
Original file line number Diff line number Diff line change
@@ -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.
Loading