-
Notifications
You must be signed in to change notification settings - Fork 2
feat(ui): add VS Code-style tabs for open pages #249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
8437bdb
feat(ui): add VS Code-style tabs for open pages
claude 59dd833
Merge branch 'master' into claude/add-tabs-architecture-9gt5m
2witstudios f22d16e
test(stores): add comprehensive tests for useOpenTabsStore
2witstudios 91202c4
feat(mobile): hide TabBar on mobile, add RecentsDropdown
2witstudios 1537c46
fix: address code review feedback
2witstudios 19c7c78
fix: address additional CodeRabbit feedback
2witstudios f9c64bb
Merge branch 'master' into claude/add-tabs-architecture-9gt5m
2witstudios File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}` : ''}`); | ||
| } | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, [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; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Middle-click on a link is handled by the browser’s
auxclickdefault action, sopreventDefault()in theonMouseDownhandler does not reliably stop the new-tab behavior. In browsers like Chrome, this will still open a new browser tab while also callingopenTabInBackground, giving users duplicate tabs (one in-app, one in the browser). To suppress the native behavior for middle-clicks, handleonAuxClick/onMouseUpand callpreventDefault()there instead.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
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
onMouseDowntoonAuxClickin TabItem.tsx for proper middle-click handling. This is the standard event for middle-click cross-browser compatibility.