diff --git a/web/src/main.ts b/web/src/main.ts index 545a389..6169a9a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -71,7 +71,23 @@ const sidebar = renderSidebar(store, { const content = document.createElement("section"); content.className = "content"; -shellEl.append(sidebar.el, content); +// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in +// the tab bar; a backdrop covers the content while it's open, and tapping a +// session closes it. On desktop these are inert (CSS gates the drawer styling). +const backdrop = document.createElement("div"); +backdrop.className = "mv-backdrop"; +function closeDrawer(): void { + shellEl.classList.remove("drawer-open"); +} +function toggleDrawer(): void { + shellEl.classList.toggle("drawer-open"); +} +backdrop.addEventListener("click", closeDrawer); +sidebar.el.addEventListener("click", (e) => { + if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer(); +}); + +shellEl.append(sidebar.el, content, backdrop); root.replaceChildren(renderTopbar(store), shellEl); // The pool owns attach connections independently of the route: navigating away @@ -172,6 +188,7 @@ mv = createMultiView({ onDisconnect: closePane, renderWelcome, onLayoutChange: scheduleSave, + onToggleSidebar: toggleDrawer, }); let contentShowsMv = false; function showMultiView(): void { diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 3b1d8c8..b8d4de5 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -36,6 +36,8 @@ export interface MultiViewOptions { /** Called after any layout change (structure, focus, or divider resize) so the * shell can persist it (issue #34). */ onLayoutChange?: () => void; + /** Toggle the mobile sidebar drawer (the ☰ button in the tab bar, issue #35). */ + onToggleSidebar?: () => void; } export interface MultiView { @@ -66,6 +68,16 @@ 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. + const mq = window.matchMedia("(max-width: 859px)"); + let isMobile = mq.matches; + mq.addEventListener("change", (e) => { + isMobile = e.matches; + render(); + }); + // ---- highlight helpers ---- function colorMap(): Map { @@ -140,23 +152,27 @@ export function createMultiView(opts: MultiViewOptions): MultiView { return b; }; // Order: disconnect (⏻) leftmost, then split-right / split-down / close. + // Splits are desktop-only; mobile keeps just disconnect + close. if (leaf.sessionId) { const off = mkBtn("⏻", "Disconnect", () => onDisconnect(leaf.sessionId!)); off.classList.add("mv-tool-off"); tools.appendChild(off); } - tools.append( - mkBtn("▥", "Split right", () => splitFocused(leaf.id, "row")), - mkBtn("▤", "Split down", () => splitFocused(leaf.id, "col")), - mkBtn("✕", "Close pane", () => closePane(leaf.id)), - ); + if (!isMobile) { + tools.append( + mkBtn("▥", "Split right", () => splitFocused(leaf.id, "row")), + mkBtn("▤", "Split down", () => splitFocused(leaf.id, "col")), + ); + } + tools.appendChild(mkBtn("✕", "Close pane", () => closePane(leaf.id))); pane.append(host, tools); pane.addEventListener( "pointerdown", (e) => { focusLeaf(leaf.id); - // Drag a pane by its title bar (not its buttons or the terminal body). + // Drag a pane by its title bar (desktop only — no docking on mobile). + if (isMobile) return; const t = e.target as HTMLElement; if (t.closest(".attach-header") && !t.closest("button, a, .mv-tool")) { runDrag({ kind: "pane", leafId: leaf.id }, e, { deferred: true }); @@ -235,6 +251,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView { function renderTabs(): void { tabBar.replaceChildren(); + // Mobile-only ☰ to open the sidebar drawer (CSS hides it on desktop). + const menu = document.createElement("button"); + menu.type = "button"; + menu.className = "mv-mobile-menu"; + menu.title = "Sessions"; + menu.textContent = "☰"; + menu.addEventListener("click", () => opts.onToggleSidebar?.()); + tabBar.appendChild(menu); for (const t of layout.tabs) { const tab = document.createElement("button"); tab.type = "button"; @@ -335,6 +359,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView { tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null; } + if (isMobile) { + renderMobile(tab); + persist(); + return; + } + const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = []; canvas.appendChild(buildTree(tab.root, hosts)); pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info }))); @@ -342,6 +372,45 @@ export function createMultiView(opts: MultiViewOptions): MultiView { persist(); } + // Mobile: a pill strip of the active tab's leaves + one full-screen pane. + 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]; + + 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"; + if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId)); + 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); + canvas.appendChild(wrap); + + // Only the focused leaf's session is live on mobile; the rest park. + pool.setVisible( + focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [], + ); + pool.setFocus(focused.sessionId ?? null); + sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null, colors: colorMap() }); + } + function closeSession(sessionId: string): void { const loc = locateSession(layout, sessionId); if (!loc) return; diff --git a/web/src/styles.css b/web/src/styles.css index 8895722..f0d2f14 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2217,3 +2217,111 @@ textarea { .tree-leaf.is-active::after { box-shadow: 0 0 8px var(--mv-color, var(--accent-ring)); } + +/* ========================================================================= + 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 (☰). + ========================================================================= */ +.mv-mobile-menu { + display: none; /* desktop: hidden — the sidebar is always present */ +} +.mv-backdrop { + display: none; +} + +.mv-mobile { + position: absolute; + inset: var(--sp-2); + display: flex; + flex-direction: column; + gap: var(--sp-2); +} +.mv-panebar { + display: flex; + gap: 4px; + 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::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--mv-color, var(--faint)); + flex: none; +} +.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; +} + +@media (max-width: 859px) { + /* ☰ lives in the tab bar */ + .mv-mobile-menu { + display: inline-grid; + place-items: center; + align-self: center; + width: 28px; + height: 28px; + margin-right: var(--sp-1); + border: none; + border-radius: var(--r-1); + background: transparent; + color: var(--ink); + font-size: var(--fs-15); + cursor: pointer; + flex: none; + } + .mv-mobile-menu:hover { + background: var(--surface-2); + } + + /* sidebar → off-canvas drawer (override the route-based show/hide) */ + .app-shell > .sidebar { + display: flex !important; + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: min(82vw, 320px); + z-index: var(--z-modal); + transform: translateX(-100%); + transition: transform var(--dur) var(--ease-out); + box-shadow: var(--shadow-2); + border-right: 1px solid var(--border); + } + .app-shell.drawer-open > .sidebar { + transform: none; + } + /* content always visible (the layout/projection); drawer floats over it */ + .app-shell[data-route="catalog"] > .content, + .app-shell > .content { + display: flex; + } + .app-shell.drawer-open > .mv-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: var(--z-backdrop); + background: oklch(0 0 0 / 0.5); + } +}