From 31158cdb1afc657711669797d02f7cf4ccf8426c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 31 Aug 2026 22:31:05 +0100 Subject: [PATCH] fix(maestro): close mobile drawer on spawn Spawn and Spawn similar open a draft pane behind the open sidebar on mobile, so the tap read as dead. Extract the drawer into its own module and close it on both spawn events as well as a session tap. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/catalog.ts | 8 +++-- web/src/drawer.test.ts | 47 +++++++++++++++++++++++++++ web/src/drawer.ts | 74 ++++++++++++++++++++++++++++++++++++++++++ web/src/main.ts | 64 +++++------------------------------- 4 files changed, 136 insertions(+), 57 deletions(-) create mode 100644 web/src/drawer.test.ts create mode 100644 web/src/drawer.ts diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 09d5e42..03d7cf0 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -37,6 +37,10 @@ export function requestOpenSpawn(): void { * connection pool. */ export const FOCUS_PANE_EVENT = "catalog-focus-pane"; +/** Ask the shell to open a draft pane seeded from an existing session + * ("Spawn similar"). Detail is the session being cloned. */ +export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar"; + /** The rows a keyboard walk stops on, in visual (document) order. Expanded * folder headers are skipped: a directory is not something to focus, and the * cards under it are one row away anyway. A *collapsed* folder still counts — @@ -169,7 +173,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { similarBtn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); - document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })); + document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })); }); const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; killBtn.addEventListener("click", (ev) => { @@ -185,7 +189,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { { label: "Spawn similar", onSelect: () => - document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })), + document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: s })), }, { label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) }, ]); diff --git a/web/src/drawer.test.ts b/web/src/drawer.test.ts new file mode 100644 index 0000000..4f8c216 --- /dev/null +++ b/web/src/drawer.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from "vitest"; + +import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js"; +import { installDrawer, type Drawer } from "./drawer.js"; + +function mount(): { shell: HTMLElement; sidebar: HTMLElement; drawer: Drawer } { + const shell = document.createElement("div"); + const sidebar = document.createElement("aside"); + const backdrop = document.createElement("div"); + const row = document.createElement("div"); + row.className = "tree-leaf"; + sidebar.appendChild(row); + shell.append(sidebar, backdrop); + document.body.replaceChildren(shell); + const drawer = installDrawer(shell, sidebar, backdrop); + drawer.open(); + return { shell, sidebar, drawer }; +} + +describe("mobile drawer", () => { + beforeEach(() => document.body.replaceChildren()); + + it("closes when a session row is tapped", () => { + const { shell, sidebar } = mount(); + (sidebar.querySelector(".tree-leaf") as HTMLElement).click(); + expect(shell.classList.contains("drawer-open")).toBe(false); + }); + + it("closes when a spawn is requested", () => { + const { shell } = mount(); + document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT)); + expect(shell.classList.contains("drawer-open")).toBe(false); + }); + + it("closes when spawn similar is requested", () => { + const { shell } = mount(); + document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} })); + expect(shell.classList.contains("drawer-open")).toBe(false); + }); + + it("leaves the backdrop click closing it", () => { + const { shell } = mount(); + (shell.lastElementChild as HTMLElement).click(); + expect(shell.classList.contains("drawer-open")).toBe(false); + }); +}); diff --git a/web/src/drawer.ts b/web/src/drawer.ts new file mode 100644 index 0000000..dc65976 --- /dev/null +++ b/web/src/drawer.ts @@ -0,0 +1,74 @@ +// Mobile off-canvas sidebar (issue #35); on desktop the `drawer-open` class is +// inert (CSS gates the drawer styling). +// +// Anything that puts a new view in the content area must close the drawer, or +// it renders behind the sidebar and the tap reads as a dead button. "Spawn +// similar" sits inside a session row but stops propagation, so it never reaches +// the row's own close — both spawn paths arrive here via document events. + +import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js"; + +export interface Drawer { + open(): void; + close(): void; + toggle(): void; + isOpen(): boolean; +} + +export function installDrawer( + shellEl: HTMLElement, + sidebarEl: HTMLElement, + backdropEl: HTMLElement, +): Drawer { + const drawer: Drawer = { + open: () => shellEl.classList.add("drawer-open"), + close: () => shellEl.classList.remove("drawer-open"), + toggle: () => shellEl.classList.toggle("drawer-open"), + isOpen: () => shellEl.classList.contains("drawer-open"), + }; + + backdropEl.addEventListener("click", () => drawer.close()); + sidebarEl.addEventListener("click", (e) => { + if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close(); + }); + document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close()); + document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close()); + + return drawer; +} + +/** Horizontal swipes drive the drawer (right opens, left closes) rather than + * the browser's back/forward navigation. Only a clearly horizontal gesture past + * a threshold counts, so vertical terminal scrolling (which owns its own touch + * handler) is untouched. */ +export function installDrawerSwipe(drawer: Drawer): void { + const mobileMq = window.matchMedia("(max-width: 859px)"); + let sx = 0; + let sy = 0; + let tracking = false; + document.addEventListener( + "touchstart", + (e) => { + tracking = mobileMq.matches && e.touches.length === 1; + if (tracking) { + sx = e.touches[0].clientX; + sy = e.touches[0].clientY; + } + }, + { passive: true }, + ); + document.addEventListener( + "touchend", + (e) => { + if (!tracking) return; + tracking = false; + const t = e.changedTouches[0]; + const dx = t.clientX - sx; + const dy = t.clientY - sy; + if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return; + if (dx > 0 && !drawer.isOpen()) drawer.open(); + else if (dx < 0 && drawer.isOpen()) drawer.close(); + }, + { passive: true }, + ); +} diff --git a/web/src/main.ts b/web/src/main.ts index e224950..6ff41be 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -4,6 +4,7 @@ import "@fontsource/jetbrains-mono/600.css"; import "@fontsource/jetbrains-mono/700.css"; import { renderSidebar, renderTopbar } from "./catalog.js"; import { ConnectionPool } from "./connections.js"; +import { installDrawer, installDrawerSwipe } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js"; import { CatalogStore } from "./events.js"; @@ -25,7 +26,7 @@ import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; -import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT } from "./catalog.js"; +import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { installViewportSizing } from "./viewport.js"; @@ -92,60 +93,13 @@ const content = document.createElement("section"); content.className = "content"; // Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in -// the header; 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). +// the header. `installDrawer` owns the close rules (session tap, Spawn, Spawn +// similar, backdrop); on desktop the class it toggles is inert. const backdrop = document.createElement("div"); backdrop.className = "mv-backdrop"; -function closeDrawer(): void { - shellEl.classList.remove("drawer-open"); -} -function openDrawer(): void { - shellEl.classList.add("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(); -}); - -// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather -// than the browser's back/forward navigation. We only act on a clearly -// horizontal gesture past a threshold, so vertical terminal scrolling (which -// owns its own touch handler) is untouched. -{ - const mobileMq = window.matchMedia("(max-width: 859px)"); - let sx = 0; - let sy = 0; - let tracking = false; - document.addEventListener( - "touchstart", - (e) => { - tracking = mobileMq.matches && e.touches.length === 1; - if (tracking) { - sx = e.touches[0].clientX; - sy = e.touches[0].clientY; - } - }, - { passive: true }, - ); - document.addEventListener( - "touchend", - (e) => { - if (!tracking) return; - tracking = false; - const t = e.changedTouches[0]; - const dx = t.clientX - sx; - const dy = t.clientY - sy; - if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return; - const open = shellEl.classList.contains("drawer-open"); - if (dx > 0 && !open) openDrawer(); - else if (dx < 0 && open) closeDrawer(); - }, - { passive: true }, - ); -} +const drawer = installDrawer(shellEl, sidebar.el, backdrop); +installDrawerSwipe(drawer); +const toggleDrawer = () => drawer.toggle(); shellEl.append(sidebar.el, content, backdrop); root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl); @@ -284,7 +238,7 @@ function requestOpenSpawn(): void { mv.openDraft(undefined, { newTab: true }); } document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); -document.addEventListener("catalog-spawn-similar", (ev) => { +document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => { const s = (ev as CustomEvent).detail; showMultiView(); mv.openDraft(spawnSimilarSeed(s)); @@ -358,7 +312,7 @@ window.addEventListener("keydown", (e) => { if (sidebar.hasListFocus()) { pool.focusTerminal(); } else { - openDrawer(); // no-op on desktop; on mobile the list is off-canvas + drawer.open(); // no-op on desktop; on mobile the list is off-canvas sidebar.focusList(); } });