From 8437bdb39e4eece89805b2919ecaadda764b9b5c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 19:01:15 +0000 Subject: [PATCH 1/5] feat(ui): add VS Code-style tabs for open pages Implements a tab system for managing multiple open pages: - Add useOpenTabsStore for persisted tab state management - Create TabBar and TabItem components with auto-hide behavior (only shows when 2+ tabs are open) - Support keyboard shortcuts: Ctrl+1-9 for tab switching, Ctrl+W to close, Ctrl+Tab to cycle - Add middle-click and Ctrl+click to open pages in background tabs - Integrate useTabSync hook to sync URL navigation with tabs - Place TabBar below TopBar as full-width accordion element The tab bar automatically hides when only one page is open, reducing UI noise while providing power-user functionality. https://claude.ai/code/session_01PWWeSnM1SQpd9Dp9LpDFmy --- apps/web/src/components/layout/Layout.tsx | 4 + .../left-sidebar/page-tree/PageTreeItem.tsx | 37 ++- .../layout/middle-content/CenterPanel.tsx | 4 + .../web/src/components/layout/tabs/TabBar.tsx | 175 ++++++++++++ .../src/components/layout/tabs/TabItem.tsx | 173 ++++++++++++ apps/web/src/components/layout/tabs/index.ts | 2 + apps/web/src/hooks/useTabSync.ts | 67 +++++ apps/web/src/stores/useOpenTabsStore.ts | 264 ++++++++++++++++++ 8 files changed, 725 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/layout/tabs/TabBar.tsx create mode 100644 apps/web/src/components/layout/tabs/TabItem.tsx create mode 100644 apps/web/src/components/layout/tabs/index.ts create mode 100644 apps/web/src/hooks/useTabSync.ts create mode 100644 apps/web/src/stores/useOpenTabsStore.ts diff --git a/apps/web/src/components/layout/Layout.tsx b/apps/web/src/components/layout/Layout.tsx index ad723f47d..f0e488860 100644 --- a/apps/web/src/components/layout/Layout.tsx +++ b/apps/web/src/components/layout/Layout.tsx @@ -6,6 +6,7 @@ import MemoizedSidebar from "@/components/layout/left-sidebar/MemoizedSidebar"; import CenterPanel from "@/components/layout/middle-content/CenterPanel"; import RightPanel from "@/components/layout/right-sidebar"; import { NavigationProvider } from "@/components/layout/NavigationProvider"; +import { TabBar } from "@/components/layout/tabs"; import { GlobalChatProvider } from "@/contexts/GlobalChatContext"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { useResponsivePanels } from "@/hooks/useResponsivePanels"; @@ -173,6 +174,9 @@ function Layout({ children }: LayoutProps) { onToggleRightPanel={handleRightPanelToggle} /> + {/* TabBar: auto-hides when <=1 tab, accordion from TopBar */} + +
{!shouldOverlaySidebars && leftSidebarOpen && (
diff --git a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx index c1eb8bd84..25c5105b2 100644 --- a/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx +++ b/apps/web/src/components/layout/left-sidebar/page-tree/PageTreeItem.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState, CSSProperties } from "react"; +import { useState, useCallback, CSSProperties, MouseEvent } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; +import { useOpenTabsStore, type TabPageType } from "@/stores/useOpenTabsStore"; import { ChevronRight, Plus, @@ -91,10 +92,42 @@ export function PageTreeItem({ const [isRenameOpen, setRenameOpen] = useState(false); const params = useParams(); const { addFavorite, removeFavorite, isFavorite } = useFavorites(); + const openTabInBackground = useOpenTabsStore((state) => state.openTabInBackground); const hasChildren = item.children && item.children.length > 0; const linkHref = `/dashboard/${params.driveId}/${item.id}`; + // Handle middle-click or Ctrl/Cmd+click to open in background tab + const handleLinkClick = useCallback((e: MouseEvent) => { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const modifier = isMac ? e.metaKey : e.ctrlKey; + + if (modifier || e.button === 1) { + e.preventDefault(); + openTabInBackground({ + id: item.id, + driveId: params.driveId as string, + title: item.title, + type: item.type as TabPageType, + }); + } + // Normal clicks are handled by useTabSync via URL change + }, [item.id, item.title, item.type, params.driveId, openTabInBackground]); + + // Handle middle-click on mousedown (for middle-click detection) + const handleMouseDown = useCallback((e: MouseEvent) => { + e.stopPropagation(); + if (e.button === 1) { + e.preventDefault(); + openTabInBackground({ + id: item.id, + driveId: params.driveId as string, + title: item.title, + type: item.type as TabPageType, + }); + } + }, [item.id, item.title, item.type, params.driveId, openTabInBackground]); + // Combine file drops AND internal dnd-kit drags for drop indicators const isFileDragOver = fileDragState?.overId === item.id; const isInternalDragOver = isOver && !isActive; @@ -241,6 +274,8 @@ export function PageTreeItem({ {/* Title - Click to Navigate */} e.stopPropagation()} className="flex-1 min-w-0 ml-1.5 truncate text-sm font-medium text-gray-900 dark:text-gray-100 hover:underline cursor-pointer" > diff --git a/apps/web/src/components/layout/middle-content/CenterPanel.tsx b/apps/web/src/components/layout/middle-content/CenterPanel.tsx index d0d7b227a..917cfdb62 100644 --- a/apps/web/src/components/layout/middle-content/CenterPanel.tsx +++ b/apps/web/src/components/layout/middle-content/CenterPanel.tsx @@ -22,6 +22,7 @@ import { memo, useState, useEffect } from 'react'; import { cn } from '@/lib/utils'; import { usePageStore } from '@/hooks/usePage'; import { useGlobalDriveSocket } from '@/hooks/useGlobalDriveSocket'; +import { useTabSync } from '@/hooks/useTabSync'; // Memoized page content component to prevent unnecessary re-renders const PageContent = memo(({ pageId }: { pageId: string | null }) => { @@ -152,6 +153,9 @@ export default function CenterPanel() { // Initialize global drive socket listener for real-time updates useGlobalDriveSocket(); + // Sync URL navigation with tabs store + useTabSync(); + // Track if GlobalAssistantView has ever been rendered (lazy mount, then persist) // This ensures we don't mount it until the user visits dashboard, but once mounted it stays const [hasRenderedGlobalAssistant, setHasRenderedGlobalAssistant] = useState(false); diff --git a/apps/web/src/components/layout/tabs/TabBar.tsx b/apps/web/src/components/layout/tabs/TabBar.tsx new file mode 100644 index 000000000..0e68c769f --- /dev/null +++ b/apps/web/src/components/layout/tabs/TabBar.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { memo, useCallback, useEffect, useRef } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { AnimatePresence, motion } from 'motion/react'; +import { useOpenTabsStore, selectHasMultipleTabs } from '@/stores/useOpenTabsStore'; +import { TabItem } from './TabItem'; +import { cn } from '@/lib/utils'; + +interface TabBarProps { + className?: string; +} + +export const TabBar = memo(function TabBar({ className }: TabBarProps) { + const router = useRouter(); + const params = useParams(); + const scrollContainerRef = useRef(null); + + const tabs = useOpenTabsStore((state) => state.tabs); + const activeTabId = useOpenTabsStore((state) => state.activeTabId); + const hasMultipleTabs = useOpenTabsStore(selectHasMultipleTabs); + const setActiveTab = useOpenTabsStore((state) => state.setActiveTab); + const setActiveTabByIndex = useOpenTabsStore((state) => state.setActiveTabByIndex); + const closeTab = useOpenTabsStore((state) => state.closeTab); + const closeOtherTabs = useOpenTabsStore((state) => state.closeOtherTabs); + const closeTabsToRight = useOpenTabsStore((state) => state.closeTabsToRight); + const pinTab = useOpenTabsStore((state) => state.pinTab); + const unpinTab = useOpenTabsStore((state) => state.unpinTab); + const cycleTab = useOpenTabsStore((state) => state.cycleTab); + + // Navigate when active tab changes + const handleActivate = useCallback((tabId: string) => { + const tab = tabs.find(t => t.id === tabId); + if (tab) { + setActiveTab(tabId); + router.push(`/dashboard/${tab.driveId}/${tab.id}`); + } + }, [tabs, setActiveTab, router]); + + // Handle close with navigation fallback + const handleClose = useCallback((tabId: string) => { + const tabIndex = tabs.findIndex(t => t.id === tabId); + const isClosingActive = tabId === activeTabId; + + closeTab(tabId); + + // If closing active tab, navigate to the new active tab + if (isClosingActive && tabs.length > 1) { + const remainingTabs = tabs.filter(t => t.id !== tabId); + if (remainingTabs.length > 0) { + // Prefer the tab at the same index, or the last one + const newActiveIndex = Math.min(tabIndex, remainingTabs.length - 1); + const newActiveTab = remainingTabs[newActiveIndex]; + router.push(`/dashboard/${newActiveTab.driveId}/${newActiveTab.id}`); + } else { + // No tabs left, go to dashboard + const driveId = params.driveId as string; + router.push(`/dashboard${driveId ? `/${driveId}` : ''}`); + } + } + }, [tabs, activeTabId, closeTab, router, params.driveId]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if in input/textarea + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const modifier = isMac ? e.metaKey : e.ctrlKey; + + // Ctrl/Cmd + 1-9: Switch to tab by index + if (modifier && !e.shiftKey && !e.altKey) { + const num = parseInt(e.key, 10); + if (num >= 1 && num <= 9) { + e.preventDefault(); + const index = num - 1; + if (index < tabs.length) { + const tab = tabs[index]; + handleActivate(tab.id); + } + return; + } + } + + // Ctrl/Cmd + W: Close current tab + if (modifier && e.key === 'w' && !e.shiftKey && !e.altKey) { + if (activeTabId) { + e.preventDefault(); + handleClose(activeTabId); + } + return; + } + + // Ctrl + Tab / Ctrl + Shift + Tab: Cycle tabs + if (e.ctrlKey && e.key === 'Tab') { + e.preventDefault(); + if (e.shiftKey) { + cycleTab('prev'); + } else { + cycleTab('next'); + } + // Navigate to the new active tab + const newActiveTabId = useOpenTabsStore.getState().activeTabId; + const newActiveTab = tabs.find(t => t.id === newActiveTabId); + if (newActiveTab) { + router.push(`/dashboard/${newActiveTab.driveId}/${newActiveTab.id}`); + } + return; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [tabs, activeTabId, handleActivate, handleClose, cycleTab, router, setActiveTabByIndex]); + + // Scroll active tab into view + useEffect(() => { + if (!scrollContainerRef.current || !activeTabId) return; + + const container = scrollContainerRef.current; + const activeElement = container.querySelector(`[aria-selected="true"]`); + + if (activeElement) { + activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + } + }, [activeTabId]); + + // Auto-hide when 0 or 1 tabs + if (!hasMultipleTabs) { + return null; + } + + return ( + + +
+ {tabs.map((tab, index) => ( + + ))} +
+
+
+ ); +}); + +export default TabBar; diff --git a/apps/web/src/components/layout/tabs/TabItem.tsx b/apps/web/src/components/layout/tabs/TabItem.tsx new file mode 100644 index 000000000..67c0897de --- /dev/null +++ b/apps/web/src/components/layout/tabs/TabItem.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { memo, useCallback } from 'react'; +import { X, Pin } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { PageTypeIcon } from '@/components/common/PageTypeIcon'; +import { useDirtyStore } from '@/stores/useDirtyStore'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; +import type { Tab, TabPageType } from '@/stores/useOpenTabsStore'; + +interface TabItemProps { + tab: Tab; + index: number; + isActive: boolean; + onActivate: (tabId: string) => void; + onClose: (tabId: string) => void; + onCloseOthers: (tabId: string) => void; + onCloseToRight: (tabId: string) => void; + onPin: (tabId: string) => void; + onUnpin: (tabId: string) => void; +} + +export const TabItem = memo(function TabItem({ + tab, + index, + isActive, + onActivate, + onClose, + onCloseOthers, + onCloseToRight, + onPin, + onUnpin, +}: TabItemProps) { + const isDirty = useDirtyStore((state) => state.isDirty(tab.id)); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + onActivate(tab.id); + }, [tab.id, onActivate]); + + const handleMiddleClick = useCallback((e: React.MouseEvent) => { + if (e.button === 1) { + e.preventDefault(); + onClose(tab.id); + } + }, [tab.id, onClose]); + + const handleCloseClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onClose(tab.id); + }, [tab.id, onClose]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onActivate(tab.id); + } + }, [tab.id, onActivate]); + + // Show keyboard shortcut number for first 9 tabs + const shortcutNumber = index < 9 ? index + 1 : null; + + return ( + + + + )} + + {/* Active tab indicator */} + {isActive && ( +
+ )} + + + + + onClose(tab.id)}> + Close + Ctrl+W + + onCloseOthers(tab.id)}> + Close Others + + onCloseToRight(tab.id)}> + Close to the Right + + + {tab.isPinned ? ( + onUnpin(tab.id)}> + Unpin Tab + + ) : ( + onPin(tab.id)}> + Pin Tab + + )} + + + ); +}); diff --git a/apps/web/src/components/layout/tabs/index.ts b/apps/web/src/components/layout/tabs/index.ts new file mode 100644 index 000000000..c8e67da71 --- /dev/null +++ b/apps/web/src/components/layout/tabs/index.ts @@ -0,0 +1,2 @@ +export { TabBar } from './TabBar'; +export { TabItem } from './TabItem'; diff --git a/apps/web/src/hooks/useTabSync.ts b/apps/web/src/hooks/useTabSync.ts new file mode 100644 index 000000000..c73ee9fc6 --- /dev/null +++ b/apps/web/src/hooks/useTabSync.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useRef } from 'react'; +import { useParams } from 'next/navigation'; +import { usePageTree } from '@/hooks/usePageTree'; +import { findNodeAndParent } from '@/lib/tree/tree-utils'; +import { useOpenTabsStore, type TabPageType } from '@/stores/useOpenTabsStore'; + +/** + * Syncs URL navigation with the tabs store. + * When a user navigates to a page (via sidebar, URL, or bookmark), + * this hook ensures a tab is opened for that page. + */ +export function useTabSync() { + const params = useParams(); + const pageId = params.pageId as string | undefined; + const driveId = params.driveId as string | undefined; + const { tree, isLoading } = usePageTree(driveId ?? ''); + const lastSyncedPageId = useRef(null); + + const openTab = useOpenTabsStore((state) => state.openTab); + const setActiveTab = useOpenTabsStore((state) => state.setActiveTab); + const tabs = useOpenTabsStore((state) => state.tabs); + const rehydrated = useOpenTabsStore((state) => state.rehydrated); + + useEffect(() => { + // Wait for store to rehydrate from localStorage + if (!rehydrated) return; + + // Skip if no page is selected or still loading + if (!pageId || !driveId || isLoading) return; + + // Skip if we already synced this page + if (lastSyncedPageId.current === pageId) return; + + // Check if tab already exists + const existingTab = tabs.find(t => t.id === pageId); + + if (existingTab) { + // Tab exists, just activate it + setActiveTab(pageId); + lastSyncedPageId.current = pageId; + return; + } + + // Find page in tree to get its info + const pageResult = findNodeAndParent(tree, pageId); + + if (!pageResult) { + // Page not found in tree yet - might still be loading + // Don't update lastSyncedPageId so we retry when tree loads + return; + } + + const { node: page } = pageResult; + + // Open new tab for this page + openTab({ + id: page.id, + driveId: driveId, + title: page.title, + type: page.type as TabPageType, + }); + + lastSyncedPageId.current = pageId; + }, [pageId, driveId, tree, isLoading, rehydrated, tabs, openTab, setActiveTab]); +} diff --git a/apps/web/src/stores/useOpenTabsStore.ts b/apps/web/src/stores/useOpenTabsStore.ts new file mode 100644 index 000000000..8efffdf57 --- /dev/null +++ b/apps/web/src/stores/useOpenTabsStore.ts @@ -0,0 +1,264 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export type TabPageType = 'FOLDER' | 'DOCUMENT' | 'CHANNEL' | 'AI_CHAT' | 'CANVAS' | 'FILE' | 'SHEET' | 'TASK_LIST'; + +export interface Tab { + id: string; // pageId + driveId: string; + title: string; + type: TabPageType; + isPinned: boolean; +} + +interface OpenTabsState { + // State + tabs: Tab[]; + activeTabId: string | null; + rehydrated: boolean; + + // Actions + setRehydrated: () => void; + openTab: (tab: Omit) => void; + openTabInBackground: (tab: Omit) => void; + closeTab: (tabId: string) => void; + closeOtherTabs: (keepTabId: string) => void; + closeTabsToRight: (tabId: string) => void; + closeAllTabs: () => void; + setActiveTab: (tabId: string) => void; + setActiveTabByIndex: (index: number) => void; + cycleTab: (direction: 'next' | 'prev') => void; + reorderTab: (fromIndex: number, toIndex: number) => void; + pinTab: (tabId: string) => void; + unpinTab: (tabId: string) => void; + updateTabTitle: (tabId: string, title: string) => void; +} + +export const useOpenTabsStore = create()( + persist( + (set, get) => ({ + // Initial state + tabs: [], + activeTabId: null, + rehydrated: false, + + setRehydrated: () => { + set({ rehydrated: true }); + }, + + openTab: (tabData) => { + const { tabs, activeTabId } = get(); + const existingTab = tabs.find(t => t.id === tabData.id); + + if (existingTab) { + // Tab already open, just activate it + set({ activeTabId: tabData.id }); + return; + } + + // Find insert position: after active tab, or at end + const activeIndex = tabs.findIndex(t => t.id === activeTabId); + const insertIndex = activeIndex >= 0 ? activeIndex + 1 : tabs.length; + + const newTab: Tab = { ...tabData, isPinned: false }; + const newTabs = [...tabs]; + newTabs.splice(insertIndex, 0, newTab); + + set({ + tabs: newTabs, + activeTabId: tabData.id, + }); + }, + + openTabInBackground: (tabData) => { + const { tabs, activeTabId } = get(); + const existingTab = tabs.find(t => t.id === tabData.id); + + if (existingTab) { + // Tab already open, don't change active + return; + } + + // Find insert position: after active tab, or at end + const activeIndex = tabs.findIndex(t => t.id === activeTabId); + const insertIndex = activeIndex >= 0 ? activeIndex + 1 : tabs.length; + + const newTab: Tab = { ...tabData, isPinned: false }; + const newTabs = [...tabs]; + newTabs.splice(insertIndex, 0, newTab); + + set({ tabs: newTabs }); + }, + + closeTab: (tabId) => { + const { tabs, activeTabId } = get(); + const tabIndex = tabs.findIndex(t => t.id === tabId); + + if (tabIndex === -1) return; + + const newTabs = tabs.filter(t => t.id !== tabId); + + // If closing active tab, activate adjacent tab + let newActiveTabId = activeTabId; + if (tabId === activeTabId) { + if (newTabs.length === 0) { + newActiveTabId = null; + } else if (tabIndex >= newTabs.length) { + // Was last tab, activate new last + newActiveTabId = newTabs[newTabs.length - 1].id; + } else { + // Activate tab that took its place + newActiveTabId = newTabs[tabIndex].id; + } + } + + set({ + tabs: newTabs, + activeTabId: newActiveTabId, + }); + }, + + closeOtherTabs: (keepTabId) => { + const { tabs } = get(); + const tabToKeep = tabs.find(t => t.id === keepTabId); + if (!tabToKeep) return; + + // Keep pinned tabs and the specified tab + const newTabs = tabs.filter(t => t.isPinned || t.id === keepTabId); + + set({ + tabs: newTabs, + activeTabId: keepTabId, + }); + }, + + closeTabsToRight: (tabId) => { + const { tabs, activeTabId } = get(); + const tabIndex = tabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return; + + // Keep tabs up to and including tabId, plus any pinned tabs to the right + const newTabs = tabs.filter((t, i) => i <= tabIndex || t.isPinned); + + // If active tab was closed, activate the reference tab + const activeStillExists = newTabs.some(t => t.id === activeTabId); + + set({ + tabs: newTabs, + activeTabId: activeStillExists ? activeTabId : tabId, + }); + }, + + closeAllTabs: () => { + const { tabs } = get(); + // Keep only pinned tabs + const pinnedTabs = tabs.filter(t => t.isPinned); + + set({ + tabs: pinnedTabs, + activeTabId: pinnedTabs.length > 0 ? pinnedTabs[0].id : null, + }); + }, + + setActiveTab: (tabId) => { + const { tabs } = get(); + if (tabs.some(t => t.id === tabId)) { + set({ activeTabId: tabId }); + } + }, + + setActiveTabByIndex: (index) => { + const { tabs } = get(); + if (index >= 0 && index < tabs.length) { + set({ activeTabId: tabs[index].id }); + } + }, + + cycleTab: (direction) => { + const { tabs, activeTabId } = get(); + if (tabs.length <= 1) return; + + const currentIndex = tabs.findIndex(t => t.id === activeTabId); + if (currentIndex === -1) return; + + let newIndex: number; + if (direction === 'next') { + newIndex = (currentIndex + 1) % tabs.length; + } else { + newIndex = (currentIndex - 1 + tabs.length) % tabs.length; + } + + set({ activeTabId: tabs[newIndex].id }); + }, + + reorderTab: (fromIndex, toIndex) => { + const { tabs } = get(); + if (fromIndex === toIndex) return; + if (fromIndex < 0 || fromIndex >= tabs.length) return; + if (toIndex < 0 || toIndex >= tabs.length) return; + + const newTabs = [...tabs]; + const [movedTab] = newTabs.splice(fromIndex, 1); + newTabs.splice(toIndex, 0, movedTab); + + set({ tabs: newTabs }); + }, + + pinTab: (tabId) => { + const { tabs } = get(); + const tabIndex = tabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return; + + const newTabs = [...tabs]; + newTabs[tabIndex] = { ...newTabs[tabIndex], isPinned: true }; + + // Move pinned tabs to the front + newTabs.sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1; + if (!a.isPinned && b.isPinned) return 1; + return 0; + }); + + set({ tabs: newTabs }); + }, + + unpinTab: (tabId) => { + const { tabs } = get(); + const tabIndex = tabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return; + + const newTabs = [...tabs]; + newTabs[tabIndex] = { ...newTabs[tabIndex], isPinned: false }; + + set({ tabs: newTabs }); + }, + + updateTabTitle: (tabId, title) => { + const { tabs } = get(); + const tabIndex = tabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return; + + const newTabs = [...tabs]; + newTabs[tabIndex] = { ...newTabs[tabIndex], title }; + + set({ tabs: newTabs }); + }, + }), + { + name: 'open-tabs-storage', + partialize: (state) => ({ + tabs: state.tabs, + activeTabId: state.activeTabId, + }), + onRehydrateStorage: () => (state) => { + state?.setRehydrated(); + }, + } + ) +); + +// Selector helpers for common patterns +export const selectTabCount = (state: OpenTabsState) => state.tabs.length; +export const selectHasMultipleTabs = (state: OpenTabsState) => state.tabs.length > 1; +export const selectActiveTab = (state: OpenTabsState) => + state.tabs.find(t => t.id === state.activeTabId) ?? null; From f22d16ea0066ab788fe1ed63b68b8593850d3e80 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Thu, 29 Jan 2026 20:00:24 -0600 Subject: [PATCH 2/5] test(stores): add comprehensive tests for useOpenTabsStore Add 43 unit tests covering all store actions: - openTab, openTabInBackground (tab creation) - closeTab, closeOtherTabs, closeTabsToRight, closeAllTabs - setActiveTab, setActiveTabByIndex, cycleTab (navigation) - pinTab, unpinTab (pin functionality) - reorderTab, updateTabTitle - Selector helpers (selectTabCount, selectHasMultipleTabs, selectActiveTab) Also refactor TabPageType to use existing PageType enum from @pagespace/lib/client-safe instead of duplicating the type. Co-Authored-By: Claude Opus 4.5 --- .../stores/__tests__/useOpenTabsStore.test.ts | 577 ++++++++++++++++++ apps/web/src/stores/useOpenTabsStore.ts | 3 +- 2 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/stores/__tests__/useOpenTabsStore.test.ts diff --git a/apps/web/src/stores/__tests__/useOpenTabsStore.test.ts b/apps/web/src/stores/__tests__/useOpenTabsStore.test.ts new file mode 100644 index 000000000..bbc6be893 --- /dev/null +++ b/apps/web/src/stores/__tests__/useOpenTabsStore.test.ts @@ -0,0 +1,577 @@ +/** + * useOpenTabsStore Tests + * Tests for VS Code-style tab management including open, close, pin, and navigation + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { useOpenTabsStore, selectTabCount, selectHasMultipleTabs, selectActiveTab } from '../useOpenTabsStore'; +import type { Tab } from '../useOpenTabsStore'; + +// Mock localStorage for persistence tests +const mockLocalStorage = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + removeItem: vi.fn((key: string) => { delete store[key]; }), + clear: vi.fn(() => { store = {}; }), + }; +})(); + +Object.defineProperty(global, 'localStorage', { value: mockLocalStorage }); + +// Test factory for creating tabs +const createTestTab = (overrides: Partial> = {}): Omit => ({ + id: overrides.id ?? 'page-1', + driveId: overrides.driveId ?? 'drive-1', + title: overrides.title ?? 'Test Page', + type: overrides.type ?? 'DOCUMENT', +}); + +describe('useOpenTabsStore', () => { + beforeEach(() => { + // Reset the store before each test + useOpenTabsStore.setState({ + tabs: [], + activeTabId: null, + rehydrated: true, + }); + mockLocalStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('initial state', () => { + it('given store is created, should have empty tabs array', () => { + const { tabs } = useOpenTabsStore.getState(); + expect(tabs).toEqual([]); + }); + + it('given store is created, should have null activeTabId', () => { + const { activeTabId } = useOpenTabsStore.getState(); + expect(activeTabId).toBeNull(); + }); + }); + + describe('openTab', () => { + it('given no existing tabs, should add tab and set as active', () => { + const { openTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + + const state = useOpenTabsStore.getState(); + expect(state.tabs).toHaveLength(1); + expect(state.tabs[0].id).toBe('page-1'); + expect(state.activeTabId).toBe('page-1'); + }); + + it('given new tab opened, should set isPinned to false', () => { + const { openTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + + expect(useOpenTabsStore.getState().tabs[0].isPinned).toBe(false); + }); + + it('given existing tab opened, should activate without duplicating', () => { + const { openTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-1' })); // Re-open first tab + + const state = useOpenTabsStore.getState(); + expect(state.tabs).toHaveLength(2); + expect(state.activeTabId).toBe('page-1'); + }); + + it('given active tab exists, should insert new tab after active tab', () => { + const { openTab, setActiveTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + + // Activate page-1, then open a new page + setActiveTab('page-1'); + openTab(createTestTab({ id: 'page-4' })); + + const tabs = useOpenTabsStore.getState().tabs; + expect(tabs.map(t => t.id)).toEqual(['page-1', 'page-4', 'page-2', 'page-3']); + }); + }); + + describe('openTabInBackground', () => { + it('given new tab opened in background, should not change active tab', () => { + const { openTab, openTabInBackground } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTabInBackground(createTestTab({ id: 'page-2' })); + + const state = useOpenTabsStore.getState(); + expect(state.tabs).toHaveLength(2); + expect(state.activeTabId).toBe('page-1'); // Still active + }); + + it('given existing tab opened in background, should not duplicate or change active', () => { + const { openTab, openTabInBackground } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTabInBackground(createTestTab({ id: 'page-1' })); + + const state = useOpenTabsStore.getState(); + expect(state.tabs).toHaveLength(2); + expect(state.activeTabId).toBe('page-2'); + }); + }); + + describe('closeTab', () => { + it('given active tab closed with tabs to the right, should activate next tab', () => { + const { openTab, closeTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + useOpenTabsStore.setState({ activeTabId: 'page-2' }); + + closeTab('page-2'); + + const state = useOpenTabsStore.getState(); + expect(state.tabs).toHaveLength(2); + expect(state.activeTabId).toBe('page-3'); // Next tab takes over + }); + + it('given last tab closed, should activate new last tab', () => { + const { openTab, closeTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + // page-3 is active (last opened) + + closeTab('page-3'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-2'); + }); + + it('given only tab closed, should set activeTabId to null', () => { + const { openTab, closeTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + closeTab('page-1'); + + const state = useOpenTabsStore.getState(); + expect(state.tabs).toHaveLength(0); + expect(state.activeTabId).toBeNull(); + }); + + it('given inactive tab closed, should not change active tab', () => { + const { openTab, closeTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + // page-3 is active + + closeTab('page-1'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-3'); + }); + + it('given non-existent tab, should do nothing', () => { + const { openTab, closeTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + + closeTab('non-existent'); + + expect(useOpenTabsStore.getState().tabs).toHaveLength(1); + }); + }); + + describe('closeOtherTabs', () => { + it('given multiple tabs, should keep only specified tab', () => { + const { openTab, closeOtherTabs } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + + closeOtherTabs('page-2'); + + const state = useOpenTabsStore.getState(); + expect(state.tabs).toHaveLength(1); + expect(state.tabs[0].id).toBe('page-2'); + expect(state.activeTabId).toBe('page-2'); + }); + + it('given pinned tabs exist, should keep pinned tabs and specified tab', () => { + const { openTab, pinTab, closeOtherTabs } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + pinTab('page-1'); + + closeOtherTabs('page-3'); + + const tabs = useOpenTabsStore.getState().tabs; + expect(tabs).toHaveLength(2); + expect(tabs.map(t => t.id)).toContain('page-1'); + expect(tabs.map(t => t.id)).toContain('page-3'); + }); + }); + + describe('closeTabsToRight', () => { + it('given tabs to the right, should close them', () => { + const { openTab, closeTabsToRight } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + openTab(createTestTab({ id: 'page-4' })); + + closeTabsToRight('page-2'); + + const tabs = useOpenTabsStore.getState().tabs; + expect(tabs.map(t => t.id)).toEqual(['page-1', 'page-2']); + }); + + it('given active tab was to the right, should activate reference tab', () => { + const { openTab, closeTabsToRight } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + // page-3 is active + + closeTabsToRight('page-1'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-1'); + }); + + it('given pinned tabs to the right, should preserve them', () => { + const { openTab, pinTab, closeTabsToRight } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + pinTab('page-3'); // Pinned tabs move to front + + // After pinning, order is: page-3(pinned), page-1, page-2 + closeTabsToRight('page-1'); + + const tabs = useOpenTabsStore.getState().tabs; + // Should keep page-3 (pinned, at front) and page-1 + expect(tabs.map(t => t.id)).toEqual(['page-3', 'page-1']); + }); + }); + + describe('closeAllTabs', () => { + it('given no pinned tabs, should close all tabs', () => { + const { openTab, closeAllTabs } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + + closeAllTabs(); + + const state = useOpenTabsStore.getState(); + expect(state.tabs).toHaveLength(0); + expect(state.activeTabId).toBeNull(); + }); + + it('given pinned tabs exist, should keep only pinned tabs', () => { + const { openTab, pinTab, closeAllTabs } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + pinTab('page-2'); + + closeAllTabs(); + + const state = useOpenTabsStore.getState(); + expect(state.tabs).toHaveLength(1); + expect(state.tabs[0].id).toBe('page-2'); + expect(state.activeTabId).toBe('page-2'); + }); + }); + + describe('setActiveTab', () => { + it('given valid tab id, should set as active', () => { + const { openTab, setActiveTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + + setActiveTab('page-1'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-1'); + }); + + it('given non-existent tab id, should not change active tab', () => { + const { openTab, setActiveTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + + setActiveTab('non-existent'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-1'); + }); + }); + + describe('setActiveTabByIndex', () => { + it('given valid index, should set tab at that index as active', () => { + const { openTab, setActiveTabByIndex } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + + setActiveTabByIndex(0); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-1'); + }); + + it('given out of bounds index, should not change active tab', () => { + const { openTab, setActiveTabByIndex } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + + setActiveTabByIndex(5); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-1'); + }); + + it('given negative index, should not change active tab', () => { + const { openTab, setActiveTabByIndex } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + + setActiveTabByIndex(-1); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-1'); + }); + }); + + describe('cycleTab', () => { + it('given next direction, should activate next tab', () => { + const { openTab, setActiveTab, cycleTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + setActiveTab('page-1'); + + cycleTab('next'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-2'); + }); + + it('given prev direction, should activate previous tab', () => { + const { openTab, setActiveTab, cycleTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + setActiveTab('page-2'); + + cycleTab('prev'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-1'); + }); + + it('given at last tab with next direction, should wrap to first', () => { + const { openTab, cycleTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + // page-3 is active (last opened) + + cycleTab('next'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-1'); + }); + + it('given at first tab with prev direction, should wrap to last', () => { + const { openTab, setActiveTab, cycleTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + setActiveTab('page-1'); + + cycleTab('prev'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-3'); + }); + + it('given only one tab, should not change active tab', () => { + const { openTab, cycleTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + + cycleTab('next'); + + expect(useOpenTabsStore.getState().activeTabId).toBe('page-1'); + }); + }); + + describe('reorderTab', () => { + it('given valid indices, should move tab to new position', () => { + const { openTab, reorderTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + + reorderTab(2, 0); // Move page-3 to first position + + const tabs = useOpenTabsStore.getState().tabs; + expect(tabs.map(t => t.id)).toEqual(['page-3', 'page-1', 'page-2']); + }); + + it('given same indices, should not change order', () => { + const { openTab, reorderTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + + reorderTab(0, 0); + + const tabs = useOpenTabsStore.getState().tabs; + expect(tabs.map(t => t.id)).toEqual(['page-1', 'page-2']); + }); + + it('given invalid fromIndex, should not change order', () => { + const { openTab, reorderTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + + reorderTab(-1, 0); + reorderTab(10, 0); + + const tabs = useOpenTabsStore.getState().tabs; + expect(tabs.map(t => t.id)).toEqual(['page-1', 'page-2']); + }); + }); + + describe('pinTab', () => { + it('given unpinned tab, should set isPinned to true', () => { + const { openTab, pinTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + pinTab('page-1'); + + expect(useOpenTabsStore.getState().tabs[0].isPinned).toBe(true); + }); + + it('given tab pinned, should move it to front of tabs', () => { + const { openTab, pinTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + + pinTab('page-3'); + + const tabs = useOpenTabsStore.getState().tabs; + expect(tabs[0].id).toBe('page-3'); + expect(tabs[0].isPinned).toBe(true); + }); + + it('given multiple tabs pinned, should group pinned tabs at front', () => { + const { openTab, pinTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + openTab(createTestTab({ id: 'page-3' })); + openTab(createTestTab({ id: 'page-4' })); + + pinTab('page-2'); + pinTab('page-4'); + + const tabs = useOpenTabsStore.getState().tabs; + expect(tabs[0].isPinned).toBe(true); + expect(tabs[1].isPinned).toBe(true); + expect(tabs[2].isPinned).toBe(false); + expect(tabs[3].isPinned).toBe(false); + }); + }); + + describe('unpinTab', () => { + it('given pinned tab, should set isPinned to false', () => { + const { openTab, pinTab, unpinTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + pinTab('page-1'); + unpinTab('page-1'); + + expect(useOpenTabsStore.getState().tabs[0].isPinned).toBe(false); + }); + + it('given non-existent tab, should not throw', () => { + const { unpinTab } = useOpenTabsStore.getState(); + + expect(() => unpinTab('non-existent')).not.toThrow(); + }); + }); + + describe('updateTabTitle', () => { + it('given valid tab, should update title', () => { + const { openTab, updateTabTitle } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1', title: 'Original' })); + updateTabTitle('page-1', 'Updated Title'); + + expect(useOpenTabsStore.getState().tabs[0].title).toBe('Updated Title'); + }); + + it('given non-existent tab, should not throw', () => { + const { updateTabTitle } = useOpenTabsStore.getState(); + + expect(() => updateTabTitle('non-existent', 'Title')).not.toThrow(); + }); + }); + + describe('selectors', () => { + it('selectTabCount should return number of tabs', () => { + const { openTab } = useOpenTabsStore.getState(); + + openTab(createTestTab({ id: 'page-1' })); + openTab(createTestTab({ id: 'page-2' })); + + expect(selectTabCount(useOpenTabsStore.getState())).toBe(2); + }); + + it('selectHasMultipleTabs should return true when more than one tab', () => { + const { openTab } = useOpenTabsStore.getState(); + + expect(selectHasMultipleTabs(useOpenTabsStore.getState())).toBe(false); + + openTab(createTestTab({ id: 'page-1' })); + expect(selectHasMultipleTabs(useOpenTabsStore.getState())).toBe(false); + + openTab(createTestTab({ id: 'page-2' })); + expect(selectHasMultipleTabs(useOpenTabsStore.getState())).toBe(true); + }); + + it('selectActiveTab should return active tab or null', () => { + const { openTab } = useOpenTabsStore.getState(); + + expect(selectActiveTab(useOpenTabsStore.getState())).toBeNull(); + + openTab(createTestTab({ id: 'page-1', title: 'Test Page' })); + const activeTab = selectActiveTab(useOpenTabsStore.getState()); + expect(activeTab?.id).toBe('page-1'); + expect(activeTab?.title).toBe('Test Page'); + }); + }); +}); diff --git a/apps/web/src/stores/useOpenTabsStore.ts b/apps/web/src/stores/useOpenTabsStore.ts index 8efffdf57..d6432ad34 100644 --- a/apps/web/src/stores/useOpenTabsStore.ts +++ b/apps/web/src/stores/useOpenTabsStore.ts @@ -1,7 +1,8 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { PageType } from '@pagespace/lib/client-safe'; -export type TabPageType = 'FOLDER' | 'DOCUMENT' | 'CHANNEL' | 'AI_CHAT' | 'CANVAS' | 'FILE' | 'SHEET' | 'TASK_LIST'; +export type TabPageType = `${PageType}`; export interface Tab { id: string; // pageId From 91202c4f4e60ff832aa83e208a3f875b9d4a190b Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Thu, 29 Jan 2026 20:37:43 -0600 Subject: [PATCH 3/5] feat(mobile): hide TabBar on mobile, add RecentsDropdown - Hide TabBar on screens < 1024px using useBreakpoint hook - Add RecentsDropdown component with clock icon for mobile navigation - Fix type error: use PageType import instead of TabPageType cast Co-Authored-By: Claude Opus 4.5 --- .../components/layout/main-header/index.tsx | 3 + .../web/src/components/layout/tabs/TabBar.tsx | 6 +- .../src/components/layout/tabs/TabItem.tsx | 5 +- .../src/components/shared/RecentsDropdown.tsx | 69 +++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/shared/RecentsDropdown.tsx diff --git a/apps/web/src/components/layout/main-header/index.tsx b/apps/web/src/components/layout/main-header/index.tsx index 3f58f5200..330ece49f 100644 --- a/apps/web/src/components/layout/main-header/index.tsx +++ b/apps/web/src/components/layout/main-header/index.tsx @@ -10,6 +10,7 @@ import VerifyEmailButton from "@/components/notifications/VerifyEmailButton"; import InlineSearch from "@/components/search/InlineSearch"; import GlobalSearch from "@/components/search/GlobalSearch"; import UserDropdown from "@/components/shared/UserDropdown"; +import RecentsDropdown from "@/components/shared/RecentsDropdown"; import { UsageCounter } from "@/components/billing/UsageCounter"; interface TopBarProps { @@ -75,6 +76,8 @@ export default function TopBar({ onToggleLeftPanel, onToggleRightPanel }: TopBar + + + + + Recent Pages + + {tabs.length === 0 ? ( +
+ No recent pages +
+ ) : ( + tabs.map((tab) => ( + handleNavigate(tab.driveId, tab.id)} + className="cursor-pointer" + > + + {tab.title} + + )) + )} +
+ + ); +} From 1537c46ff32dfbda1dbf5014b6a33cb5898bea0f Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Thu, 29 Jan 2026 20:42:56 -0600 Subject: [PATCH 4/5] fix: address code review feedback - Fix handleClose to navigate to dashboard when last tab is closed - Fix useTabSync to reset lastSyncedPageId when navigating away - Fix pinTab to use stable sort preserving original order - Fix onAuxClick instead of onMouseDown for middle-click handling - Fix openTab/openTabInBackground to insert after all pinned tabs Co-Authored-By: Claude Opus 4.5 --- .claude/ralph-loop.local.md | 9 ++++ .../web/src/components/layout/tabs/TabBar.tsx | 6 +-- .../src/components/layout/tabs/TabItem.tsx | 8 ++-- apps/web/src/hooks/useTabSync.ts | 10 +++- apps/web/src/stores/useOpenTabsStore.ts | 47 ++++++++++++++----- 5 files changed, 57 insertions(+), 23 deletions(-) create mode 100644 .claude/ralph-loop.local.md diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md new file mode 100644 index 000000000..f0af915d1 --- /dev/null +++ b/.claude/ralph-loop.local.md @@ -0,0 +1,9 @@ +--- +active: true +iteration: 1 +max_iterations: 40 +completion_promise: "PR_READY" +started_at: "2026-01-30T02:36:54Z" +--- + +TASK: Converge the current open Pull Request to merge-ready by addressing every review comment, replying to each thread, ensuring all CI checks pass, and completing the existing implementation plan.\n\nSUCCESS CRITERIA:\n- PR has ZERO unresolved review threads/conversations.\n- Every reviewer comment has been explicitly acknowledged with a reply explaining what changed (or why no change is needed).\n- All required CI checks are green (no failing or pending required checks).\n- The original plan (the one this PR is based on) is fully completed: all planned tasks are implemented, and any plan checklist/tracker is updated to show completion.\n- Repo validations pass locally where applicable (tests/build/lint/typecheck for this repo), or CI equivalents are confirmed green.\n- PR description is up to date (summarize what changed + how to validate), and no TODO/FIXME markers remain related to review feedback.\n\nPROCESS (repeat until success):\n1) Discover the PR context: identify the PR number/link, read the PR description, commits, files changed, and the plan/tracker the PR references.\n2) Collect all feedback: list every review comment + thread, label each as (a) code change required, (b) question/clarification, (c) optional suggestion, (d) out-of-scope.\n3) Pick the smallest actionable item (ONE thread at a time): implement the minimal change that resolves it.\n4) Run the fastest relevant local validation (targeted tests/lint/typecheck/build). If not available, rely on CI but still do best-effort local checks.\n5) Commit with a clear message referencing the thread/topic. Push.\n6) Reply in the PR thread describing exactly what you changed and where (files/lines), and mark the thread resolved if appropriate.\n7) Re-check CI status. If failing, fix the failure, push, and update any relevant PR replies.\n8) Repeat until all threads are resolved AND all required checks are green AND the plan is complete.\n\nCOMMUNICATION RULES:\n- Always reply politely and concretely. If disagreeing, explain why and propose an alternative.\n- If a comment requires a product/architecture decision that you cannot infer from context, ask a single concise question in the PR and create a TODO note, then continue with other threads.\n\nESCAPE HATCH:\n- After 25 iterations, if not complete, output BLOCKED and include: (1) remaining unresolved threads with links/quotes, (2) latest CI failures with logs summary, (3) what you tried, (4) the minimal questions needed from a human to proceed.\n\nOUTPUT: Only output PR_READY when ALL success criteria are met. diff --git a/apps/web/src/components/layout/tabs/TabBar.tsx b/apps/web/src/components/layout/tabs/TabBar.tsx index 427a59651..d0252bde7 100644 --- a/apps/web/src/components/layout/tabs/TabBar.tsx +++ b/apps/web/src/components/layout/tabs/TabBar.tsx @@ -43,12 +43,12 @@ export const TabBar = memo(function TabBar({ className }: TabBarProps) { const handleClose = useCallback((tabId: string) => { const tabIndex = tabs.findIndex(t => t.id === tabId); const isClosingActive = tabId === activeTabId; + const remainingTabs = tabs.filter(t => t.id !== tabId); closeTab(tabId); - // If closing active tab, navigate to the new active tab - if (isClosingActive && tabs.length > 1) { - const remainingTabs = tabs.filter(t => t.id !== tabId); + // If closing active tab, navigate to the new active tab or dashboard + if (isClosingActive) { if (remainingTabs.length > 0) { // Prefer the tab at the same index, or the last one const newActiveIndex = Math.min(tabIndex, remainingTabs.length - 1); diff --git a/apps/web/src/components/layout/tabs/TabItem.tsx b/apps/web/src/components/layout/tabs/TabItem.tsx index e8e6f08f8..20d533f75 100644 --- a/apps/web/src/components/layout/tabs/TabItem.tsx +++ b/apps/web/src/components/layout/tabs/TabItem.tsx @@ -46,10 +46,8 @@ export const TabItem = memo(function TabItem({ }, [tab.id, onActivate]); const handleMiddleClick = useCallback((e: React.MouseEvent) => { - if (e.button === 1) { - e.preventDefault(); - onClose(tab.id); - } + e.preventDefault(); + onClose(tab.id); }, [tab.id, onClose]); const handleCloseClick = useCallback((e: React.MouseEvent) => { @@ -76,7 +74,7 @@ export const TabItem = memo(function TabItem({ aria-selected={isActive} tabIndex={isActive ? 0 : -1} onClick={handleClick} - onMouseDown={handleMiddleClick} + onAuxClick={handleMiddleClick} onKeyDown={handleKeyDown} className={cn( "group relative flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium", diff --git a/apps/web/src/hooks/useTabSync.ts b/apps/web/src/hooks/useTabSync.ts index c73ee9fc6..cbaf260e8 100644 --- a/apps/web/src/hooks/useTabSync.ts +++ b/apps/web/src/hooks/useTabSync.ts @@ -27,8 +27,14 @@ export function useTabSync() { // Wait for store to rehydrate from localStorage if (!rehydrated) return; - // Skip if no page is selected or still loading - if (!pageId || !driveId || isLoading) return; + // Reset sync state when navigating away from a page + if (!pageId || !driveId) { + lastSyncedPageId.current = null; + return; + } + + // Skip if still loading tree + if (isLoading) return; // Skip if we already synced this page if (lastSyncedPageId.current === pageId) return; diff --git a/apps/web/src/stores/useOpenTabsStore.ts b/apps/web/src/stores/useOpenTabsStore.ts index d6432ad34..29bd9802a 100644 --- a/apps/web/src/stores/useOpenTabsStore.ts +++ b/apps/web/src/stores/useOpenTabsStore.ts @@ -57,9 +57,18 @@ export const useOpenTabsStore = create()( return; } - // Find insert position: after active tab, or at end + // Find insert position: after active tab, or after all pinned tabs + const activeTab = tabs.find(t => t.id === activeTabId); const activeIndex = tabs.findIndex(t => t.id === activeTabId); - const insertIndex = activeIndex >= 0 ? activeIndex + 1 : tabs.length; + + let insertIndex: number; + if (activeTab?.isPinned) { + // If active tab is pinned, insert after all pinned tabs + const lastPinnedIndex = tabs.reduce((last, t, i) => t.isPinned ? i : last, -1); + insertIndex = lastPinnedIndex + 1; + } else { + insertIndex = activeIndex >= 0 ? activeIndex + 1 : tabs.length; + } const newTab: Tab = { ...tabData, isPinned: false }; const newTabs = [...tabs]; @@ -80,9 +89,18 @@ export const useOpenTabsStore = create()( return; } - // Find insert position: after active tab, or at end + // Find insert position: after active tab, or after all pinned tabs + const activeTab = tabs.find(t => t.id === activeTabId); const activeIndex = tabs.findIndex(t => t.id === activeTabId); - const insertIndex = activeIndex >= 0 ? activeIndex + 1 : tabs.length; + + let insertIndex: number; + if (activeTab?.isPinned) { + // If active tab is pinned, insert after all pinned tabs + const lastPinnedIndex = tabs.reduce((last, t, i) => t.isPinned ? i : last, -1); + insertIndex = lastPinnedIndex + 1; + } else { + insertIndex = activeIndex >= 0 ? activeIndex + 1 : tabs.length; + } const newTab: Tab = { ...tabData, isPinned: false }; const newTabs = [...tabs]; @@ -210,17 +228,20 @@ export const useOpenTabsStore = create()( const tabIndex = tabs.findIndex(t => t.id === tabId); if (tabIndex === -1) return; - const newTabs = [...tabs]; - newTabs[tabIndex] = { ...newTabs[tabIndex], isPinned: true }; - - // Move pinned tabs to the front - newTabs.sort((a, b) => { - if (a.isPinned && !b.isPinned) return -1; - if (!a.isPinned && b.isPinned) return 1; - return 0; + // Create tabs with original indices for stable sort + const tabsWithIndex = tabs.map((t, i) => ({ + tab: i === tabIndex ? { ...t, isPinned: true } : t, + originalIndex: i, + })); + + // Stable sort: pinned first, then by original index + tabsWithIndex.sort((a, b) => { + if (a.tab.isPinned && !b.tab.isPinned) return -1; + if (!a.tab.isPinned && b.tab.isPinned) return 1; + return a.originalIndex - b.originalIndex; }); - set({ tabs: newTabs }); + set({ tabs: tabsWithIndex.map(({ tab }) => tab) }); }, unpinTab: (tabId) => { From 19c7c7888071851b01d0a205e597e69c5a5f54f2 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Thu, 29 Jan 2026 20:55:41 -0600 Subject: [PATCH 5/5] fix: address additional CodeRabbit feedback - Add e.button === 1 check to handleMiddleClick to only trigger on middle-click (not right-click) - Change outer tab button to div with role="tab" to avoid nested interactive elements (close button inside button is invalid HTML) - Added cursor-pointer class to maintain visual affordance Co-Authored-By: Claude Opus 4.5 --- apps/web/src/components/layout/tabs/TabItem.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/layout/tabs/TabItem.tsx b/apps/web/src/components/layout/tabs/TabItem.tsx index 20d533f75..c41a18cf4 100644 --- a/apps/web/src/components/layout/tabs/TabItem.tsx +++ b/apps/web/src/components/layout/tabs/TabItem.tsx @@ -46,6 +46,8 @@ export const TabItem = memo(function TabItem({ }, [tab.id, onActivate]); const handleMiddleClick = useCallback((e: React.MouseEvent) => { + // Only handle middle-click (button 1), not right-click + if (e.button !== 1) return; e.preventDefault(); onClose(tab.id); }, [tab.id, onClose]); @@ -68,8 +70,7 @@ export const TabItem = memo(function TabItem({ return ( - +