Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude/ralph-loop.local.md
Original file line number Diff line number Diff line change
@@ -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 <promise>BLOCKED</promise> 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 <promise>PR_READY</promise> when ALL success criteria are met.
4 changes: 4 additions & 0 deletions apps/web/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,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";
Expand Down Expand Up @@ -197,6 +198,9 @@ function Layout({ children }: LayoutProps) {
onToggleRightPanel={handleRightPanelToggle}
/>

{/* TabBar: auto-hides when <=1 tab, accordion from TopBar */}
<TabBar />

<div className="relative flex flex-1 min-h-0 overflow-hidden">
{!shouldOverlaySidebars && leftSidebarOpen && (
<div className="relative hidden flex-shrink-0 xl:flex xl:w-[18rem] 2xl:w-80 pt-4 overflow-hidden">
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
FolderPlus,
Expand Down Expand Up @@ -93,11 +94,43 @@ export function PageTreeItem({
const [isRenameOpen, setRenameOpen] = useState(false);
const params = useParams();
const { addFavorite, removeFavorite, isFavorite } = useFavorites();
const openTabInBackground = useOpenTabsStore((state) => state.openTabInBackground);
const isTouchDevice = useTouchDevice();
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<HTMLAnchorElement>) => {
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<HTMLAnchorElement>) => {
e.stopPropagation();
if (e.button === 1) {
e.preventDefault();
Comment on lines +120 to +124

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent middle-click default on auxclick, not mousedown

Middle-click on a link is handled by the browser’s auxclick default action, so preventDefault() in the onMouseDown handler does not reliably stop the new-tab behavior. In browsers like Chrome, this will still open a new browser tab while also calling openTabInBackground, giving users duplicate tabs (one in-app, one in the browser). To suppress the native behavior for middle-clicks, handle onAuxClick/onMouseUp and call preventDefault() there instead.

Useful? React with 👍 / 👎.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 1537c46. Changed onMouseDown to onAuxClick in TabItem.tsx for proper middle-click handling. This is the standard event for middle-click cross-browser compatibility.

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;
Expand Down Expand Up @@ -244,6 +277,8 @@ export function PageTreeItem({
{/* Title - Click to Navigate */}
<Link
href={linkHref}
onClick={handleLinkClick}
onMouseDown={handleMouseDown}
onPointerDown={(e) => e.stopPropagation()}
onTouchEnd={(e) => 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 touch-manipulation"
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/layout/main-header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -75,6 +76,8 @@ export default function TopBar({ onToggleLeftPanel, onToggleRightPanel }: TopBar

<NotificationBell />

<RecentsDropdown className="lg:hidden" />

<Button
variant="ghost"
size="icon"
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/layout/middle-content/CenterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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);
Expand Down
177 changes: 177 additions & 0 deletions apps/web/src/components/layout/tabs/TabBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"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 { useBreakpoint } from '@/hooks/useBreakpoint';
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<HTMLDivElement>(null);
const isMobile = useBreakpoint('(max-width: 1023px)');

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;
const remainingTabs = tabs.filter(t => t.id !== tabId);

closeTab(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);
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 on mobile or when 0 or 1 tabs
if (isMobile || !hasMultipleTabs) {
return null;
}

return (
<AnimatePresence>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className={cn(
"flex-shrink-0 border-b border-[var(--separator)] bg-muted/20 overflow-hidden",
className
)}
>
<div
ref={scrollContainerRef}
role="tablist"
aria-label="Open pages"
className="flex items-stretch overflow-x-auto scrollbar-none"
>
{tabs.map((tab, index) => (
<TabItem
key={tab.id}
tab={tab}
index={index}
isActive={tab.id === activeTabId}
onActivate={handleActivate}
onClose={handleClose}
onCloseOthers={closeOtherTabs}
onCloseToRight={closeTabsToRight}
onPin={pinTab}
onUnpin={unpinTab}
/>
))}
</div>
</motion.div>
</AnimatePresence>
);
});

export default TabBar;
Loading