From e0d976ac929e49bc2a959509e3684f070ed4391d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 17:37:24 -0700 Subject: [PATCH] fix(tabs): track active tab correctly on close + long-press reorder currentModuleId was a const that never updated on client-side tab switches, causing close to either do nothing or switch to the wrong tab. Now uses tabBar.active as source of truth and picks the nearest remaining tab on close. Also adds touch long-press (400ms) drag reorder for mobile tabs. Co-Authored-By: Claude Opus 4.6 --- server/shell.ts | 18 ++++-- shared/components/rstack-tab-bar.ts | 91 ++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/server/shell.ts b/server/shell.ts index b38f644..8cc2618 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -479,7 +479,7 @@ export function renderShell(opts: ShellOptions): string { // the next page load reads the existing tabs and adds the new module. const tabBar = document.querySelector('rstack-tab-bar'); const spaceSlug = '${escapeAttr(spaceSlug)}'; - const currentModuleId = '${escapeAttr(moduleId)}'; + let currentModuleId = '${escapeAttr(moduleId)}'; const TABS_KEY = 'rspace_tabs_' + spaceSlug; const moduleList = ${moduleListJSON}; @@ -611,6 +611,7 @@ export function renderShell(opts: ShellOptions): string { // This prevents the visual desync where the tab highlights before content loads. tabBar.addEventListener('layer-switch', (e) => { const { layerId, moduleId } = e.detail; + currentModuleId = moduleId; saveTabs(); // Update settings panel to show config for the newly active module const sp = document.querySelector('rstack-space-settings'); @@ -630,6 +631,7 @@ export function renderShell(opts: ShellOptions): string { tabBar.addEventListener('layer-add', (e) => { const { moduleId } = e.detail; + currentModuleId = moduleId; if (!layers.find(l => l.moduleId === moduleId)) { layers.push(makeLayer(moduleId, layers.length)); } @@ -652,6 +654,9 @@ export function renderShell(opts: ShellOptions): string { const { layerId } = e.detail; const closedLayer = layers.find(l => l.id === layerId); const closedModuleId = layerId.replace('layer-', ''); + const closedIdx = layers.findIndex(l => l.id === layerId); + // Use tabBar.active as source of truth (always updated by TabCache) + const wasActive = layerId === tabBar.getAttribute('active'); tabBar.removeLayer(layerId); layers = layers.filter(l => l.id !== layerId); saveTabs(); @@ -682,10 +687,13 @@ export function renderShell(opts: ShellOptions): string { if (app) app.classList.remove('canvas-layout'); tabBar.setAttribute('active', ''); tabBar.setLayers([]); + currentModuleId = ''; history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', '/' + spaceSlug); - } else if (layerId === 'layer-' + currentModuleId) { - // Closed the active tab — switch to the first remaining - const nextModuleId = layers[0].moduleId; + } else if (wasActive) { + // Closed the active tab — switch to the nearest remaining tab + const nextLayer = layers[Math.min(closedIdx, layers.length - 1)] || layers[0]; + const nextModuleId = nextLayer.moduleId; + currentModuleId = nextModuleId; if (tabCache) { tabCache.switchTo(nextModuleId).then(ok => { if (!ok) window.location.href = window.__rspaceNavUrl(spaceSlug, nextModuleId); @@ -720,6 +728,7 @@ export function renderShell(opts: ShellOptions): string { } const modId = targetModule || 'rspace'; + currentModuleId = modId; // Add tab if not already present if (!layers.find(l => l.moduleId === modId)) { layers.push(makeLayer(modId, layers.length)); @@ -802,6 +811,7 @@ export function renderShell(opts: ShellOptions): string { tabCache.switchTo(moduleId); return; } + currentModuleId = moduleId; // Add tab if not already open if (!layers.find(l => l.moduleId === moduleId)) { const newLayer = makeLayer(moduleId, layers.length); diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index bcda473..437dcad 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -117,6 +117,13 @@ export class RStackTabBar extends HTMLElement { #escHandler: ((e: KeyboardEvent) => void) | null = null; // Cleanup for document-level listeners (prevent leak on re-render) #docCleanup: (() => void) | null = null; + // Touch-drag reorder state (long-press to reorder on mobile) + #touchDragTimer: ReturnType | null = null; + #touchDragActive = false; + #touchDragLayerId: string | null = null; + #touchDragClone: HTMLElement | null = null; + #touchStartX = 0; + #touchStartY = 0; // Recent apps: moduleId → last-used timestamp #recentApps: Map = new Map(); @@ -941,8 +948,7 @@ export class RStackTabBar extends HTMLElement { // The shell's event handler calls switchTo() and sets active only after success. this.#shadow.querySelectorAll(".tab").forEach(tab => { tab.addEventListener("click", (e) => { - const target = e.target as HTMLElement; - if (target.classList.contains("tab-close")) return; + if ((e.target as HTMLElement).closest(".tab-close")) return; const layerId = tab.dataset.layerId!; const moduleId = tab.dataset.moduleId!; this.trackRecent(moduleId); @@ -985,6 +991,86 @@ export class RStackTabBar extends HTMLElement { bubbles: true, })); }); + + // Touch long-press reorder (mobile) + tab.addEventListener("touchstart", (e) => { + if ((e.target as HTMLElement).closest(".tab-close")) return; + const touch = e.touches[0]; + this.#touchStartX = touch.clientX; + this.#touchStartY = touch.clientY; + this.#touchDragTimer = setTimeout(() => { + this.#touchDragActive = true; + this.#touchDragLayerId = tab.dataset.layerId!; + tab.classList.add("dragging"); + // Haptic feedback if available + if (navigator.vibrate) navigator.vibrate(30); + // Create floating clone + const rect = tab.getBoundingClientRect(); + const clone = tab.cloneNode(true) as HTMLElement; + clone.className = "tab active touch-drag-clone"; + clone.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;z-index:9999;pointer-events:none;opacity:0.85;box-shadow:0 4px 16px rgba(0,0,0,0.4);`; + this.#shadow.appendChild(clone); + this.#touchDragClone = clone; + }, 400); + }, { passive: true }); + + tab.addEventListener("touchmove", (e) => { + const touch = e.touches[0]; + // Cancel long-press if finger moved too far before activation + if (!this.#touchDragActive && this.#touchDragTimer) { + const dx = touch.clientX - this.#touchStartX; + const dy = touch.clientY - this.#touchStartY; + if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { + clearTimeout(this.#touchDragTimer); + this.#touchDragTimer = null; + } + return; + } + if (!this.#touchDragActive) return; + e.preventDefault(); + // Move the floating clone + if (this.#touchDragClone) { + this.#touchDragClone.style.left = `${touch.clientX - this.#touchDragClone.offsetWidth / 2}px`; + this.#touchDragClone.style.top = `${touch.clientY - this.#touchDragClone.offsetHeight - 10}px`; + } + // Highlight the tab under the finger + this.#shadow.querySelectorAll(".tab").forEach(t => t.classList.remove("drag-over")); + const elUnder = this.#shadow.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement | null; + const tabUnder = elUnder?.closest?.(".tab") as HTMLElement | null; + if (tabUnder && tabUnder.dataset.layerId !== this.#touchDragLayerId) { + tabUnder.classList.add("drag-over"); + } + }, { passive: false }); + + tab.addEventListener("touchend", (e) => { + if (this.#touchDragTimer) { clearTimeout(this.#touchDragTimer); this.#touchDragTimer = null; } + if (!this.#touchDragActive) return; + e.preventDefault(); + // Find drop target + const touch = e.changedTouches[0]; + this.#shadow.querySelectorAll(".tab").forEach(t => t.classList.remove("drag-over", "dragging")); + const elUnder = this.#shadow.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement | null; + const tabUnder = elUnder?.closest?.(".tab") as HTMLElement | null; + if (tabUnder && tabUnder.dataset.layerId !== this.#touchDragLayerId) { + const targetIdx = this.#layers.findIndex(l => l.id === tabUnder.dataset.layerId); + this.dispatchEvent(new CustomEvent("layer-reorder", { + detail: { layerId: this.#touchDragLayerId, newIndex: targetIdx }, + bubbles: true, + })); + } + // Cleanup + if (this.#touchDragClone) { this.#touchDragClone.remove(); this.#touchDragClone = null; } + this.#touchDragActive = false; + this.#touchDragLayerId = null; + }); + + tab.addEventListener("touchcancel", () => { + if (this.#touchDragTimer) { clearTimeout(this.#touchDragTimer); this.#touchDragTimer = null; } + if (this.#touchDragClone) { this.#touchDragClone.remove(); this.#touchDragClone = null; } + this.#shadow.querySelectorAll(".tab").forEach(t => t.classList.remove("drag-over", "dragging")); + this.#touchDragActive = false; + this.#touchDragLayerId = null; + }); }); // Close buttons @@ -1511,6 +1597,7 @@ const STYLES = ` .tab { cursor: grab; } .tab.dragging { opacity: 0.4; cursor: grabbing; } .tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; } +.touch-drag-clone { transition: none; border-radius: 6px; } /* ── Add button ── */