diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts index 012693e..4e9a8de 100644 --- a/web/src/multiview.test.ts +++ b/web/src/multiview.test.ts @@ -432,3 +432,94 @@ describe("multiview closeSession tab cleanup (#137)", () => { expect(layout.activeTabId).toBe(tab.id); }); }); + +describe("multiview mobile single pane", () => { + /** Force the mobile media query on, whatever happy-dom's viewport says. */ + function forceMobile(): void { + vi.spyOn(window, "matchMedia").mockImplementation( + (query: string) => + ({ + matches: query.includes("859"), + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList, + ); + } + + beforeEach(() => { + stubFetch(); + forceMobile(); + document.body.replaceChildren(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("replaces the focused pane instead of splitting beside it", () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.openSessionBeside("s2"); + + const ls = leaves(tab.root); + expect(ls).toHaveLength(1); + expect(ls[0].sessionId).toBe("s2"); + expect(layout.tabs).toHaveLength(1); + }); + + it("keeps a spawn draft in the current pane rather than a new tab", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.openDraft(undefined, { newTab: true }); + await flush(); + + expect(layout.tabs).toHaveLength(1); + const ls = leaves(tab.root); + expect(ls).toHaveLength(1); + expect(ls[0].sessionId).toBeNull(); + expect(ls[0].draft).not.toBeNull(); + }); + + it("renders one pane and no pane pills for a multi-leaf tab", () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const first = splitLeaf(tab, "", "s1", "row", true); + tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(1); + expect(mv.el.querySelector(".mv-panebar")).toBeNull(); + expect(mv.el.querySelector(".mv-pill")).toBeNull(); + }); + + it("collapses the tab bar even with several tabs", () => { + const layout = emptyLayout(); + const first = layout.tabs[0]; + first.focusedLeafId = splitLeaf(first, "", "s1", "row", true); + const second = addTab(layout); + second.focusedLeafId = splitLeaf(second, "", "s2", "row", true); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true); + }); +}); diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 3da26a8..1408a05 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -84,9 +84,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView { canvas.className = "mv-canvas"; el.append(tabBar, canvas); - // Mobile (issue #35): below 860px the tree projects to a one-pane-at-a-time - // strip of pills — splits/drag/resize are desktop-only. The tree is untouched, - // so widening restores the splits. + // Mobile (issue #35): below 860px the layout collapses to exactly one visible + // pane and no tab bar — splits/drag/resize are desktop-only, and opening or + // spawning replaces the pane rather than adding a second thing to navigate. + // The sidebar drawer is the only navigation. The tree is untouched, so + // widening restores whatever splits and tabs a desktop session built. const mq = window.matchMedia("(max-width: 859px)"); let isMobile = mq.matches; mq.addEventListener("change", (e) => { @@ -391,7 +393,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView { return; } const target = findLeaf(tab.root, tab.focusedLeafId)!; - if (!target.sessionId) { + // Mobile shows one pane, so there is no "beside": take over the pane. The + // evicted session keeps running and stays a tap away in the sidebar. + if (!target.sessionId || isMobile) { target.sessionId = sessionId; target.draft = null; } else { @@ -403,7 +407,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView { function openDraft(seed?: Partial, opts?: { newTab?: boolean }): void { const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed }); - if (opts?.newTab) { + // Mobile keeps a single tab and a single pane: the spawn form takes over the + // current pane instead of opening a tab or a split. + if (opts?.newTab && !isMobile) { // Spawn opens its own tab rather than halving whatever you were reading // (#100). The empty-pane reuse below still applies within a tab. const fresh = addTab(layout); @@ -421,9 +427,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView { return; } const target = findLeaf(tab.root, tab.focusedLeafId)!; - if (!target.sessionId) { + if (!target.sessionId || isMobile) { // Empty = no session, not `!draft` (#72): the mounted form auto-syncs // picked defaults into `draft` once ready, so that's never reliably null. + target.sessionId = null; target.draft = draft(); const stale = draftForms.get(target.id); if (stale) { @@ -462,9 +469,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView { renderTabs(); canvas.replaceChildren(); const tab = activeTab(layout); - // Mobile single-tab: the tab bar carries no information (one tab, one pane), - // so CSS collapses it and we float a ☰ instead (rendered in renderMobile). - el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1); + // Mobile: the tab bar is a second navigation surface we don't want, so CSS + // collapses it; the header ☰ keeps the sidebar drawer reachable. + el.classList.toggle("mv-mobile-solo", isMobile); if (!tab.root) { pruneForms(tab); @@ -496,7 +503,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView { persist(); } - // Mobile: a pill strip of the active tab's leaves + one full-screen pane. + // Mobile: the focused leaf, full screen, alone. Panes a desktop session left + // behind stay in the tree but are reached only by tapping the session in the + // sidebar drawer — mobile shows no pane switcher of its own. function renderMobile(tab: { root: Node | null; focusedLeafId: string | null }): void { const ls = leaves(tab.root); const focused = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || ls[0]; @@ -504,23 +513,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const wrap = document.createElement("div"); wrap.className = "mv-mobile"; - if (ls.length > 1) { - const bar = document.createElement("div"); - bar.className = "mv-panebar"; - for (const l of ls) { - const pill = document.createElement("button"); - pill.type = "button"; - pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : ""); - pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty"; - pill.addEventListener("click", () => { - tab.focusedLeafId = l.id; - render(); - }); - bar.appendChild(pill); - } - wrap.appendChild(bar); - } - const pane = buildPane(focused); pane.el.classList.add("is-focused"); // the only pane — never dimmed wrap.appendChild(pane.el); diff --git a/web/src/styles.css b/web/src/styles.css index 9663809..d73701f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2202,8 +2202,8 @@ input.leaf-rename:focus { display: flex; flex-direction: column; } -/* Mobile, single tab: the tab bar is pure chrome — collapse it (a floating ☰ - inside the pane keeps the sidebar drawer reachable). */ +/* Mobile: the tab bar is a second navigation surface — collapse it (a floating + ☰ inside the pane keeps the sidebar drawer reachable). */ .mv-mobile-solo > .mv-tabs { display: none; } @@ -2482,8 +2482,8 @@ input.leaf-rename:focus { } /* ========================================================================= - multi-view mobile (#35): below 860px the split tree projects to a pill strip - with one pane visible; the sidebar becomes an off-canvas drawer (☰). + multi-view mobile (#35): below 860px only the focused pane is shown — no tab + bar, no pane switcher; the sidebar drawer (☰) is the navigation. ========================================================================= */ .mv-backdrop { display: none; @@ -2503,32 +2503,6 @@ input.leaf-rename:focus { border-radius: 0; box-shadow: none; } -.mv-panebar { - display: flex; - gap: 4px; - padding: var(--sp-1) var(--sp-2); - overflow-x: auto; - scrollbar-width: none; - flex: none; -} -.mv-pill { - display: inline-flex; - align-items: center; - gap: var(--sp-2); - padding: 7px var(--sp-3); - border-radius: var(--r-1); - background: var(--surface-2); - color: var(--muted); - font: 600 var(--fs-12) / 1 var(--font-mono); - white-space: nowrap; - cursor: pointer; - border: 1px solid transparent; -} -.mv-pill.is-active { - background: var(--accent-tint); - color: var(--accent-strong); - border-color: var(--accent-ring); -} .mv-mobile > .mv-pane { flex: 1; min-height: 0;