From 9908c17b852e9eebfb290c81153278d2a2004bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 9 Apr 2026 00:31:58 +0800 Subject: [PATCH] feat: add TabBar component with scroll overflow and context menu Co-Authored-By: Claude Sonnet 4.6 --- desktop/src/components/layout/TabBar.tsx | 220 +++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 desktop/src/components/layout/TabBar.tsx diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx new file mode 100644 index 00000000..81ac57e7 --- /dev/null +++ b/desktop/src/components/layout/TabBar.tsx @@ -0,0 +1,220 @@ +import { useRef, useState, useEffect, useCallback } from 'react' +import { useTabStore, type Tab } from '../../stores/tabStore' +import { useChatStore } from '../../stores/chatStore' +import { useTranslation } from '../../i18n' + +const TAB_WIDTH = 180 + +export function TabBar() { + const tabs = useTabStore((s) => s.tabs) + const activeTabId = useTabStore((s) => s.activeTabId) + const setActiveTab = useTabStore((s) => s.setActiveTab) + const closeTab = useTabStore((s) => s.closeTab) + const disconnectSession = useChatStore((s) => s.disconnectSession) + + const scrollRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null) + const [closingTabId, setClosingTabId] = useState(null) + const t = useTranslation() + + const updateScrollState = useCallback(() => { + const el = scrollRef.current + if (!el) return + setCanScrollLeft(el.scrollLeft > 0) + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1) + }, []) + + useEffect(() => { + updateScrollState() + const el = scrollRef.current + if (!el) return + el.addEventListener('scroll', updateScrollState) + const ro = new ResizeObserver(updateScrollState) + ro.observe(el) + return () => { + el.removeEventListener('scroll', updateScrollState) + ro.disconnect() + } + }, [updateScrollState, tabs.length]) + + useEffect(() => { + if (!contextMenu) return + const close = () => setContextMenu(null) + document.addEventListener('click', close) + return () => document.removeEventListener('click', close) + }, [contextMenu]) + + const scroll = (direction: 'left' | 'right') => { + const el = scrollRef.current + if (!el) return + el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' }) + } + + const handleClose = (sessionId: string) => { + const sessionState = useChatStore.getState().sessions[sessionId] + const isRunning = sessionState && sessionState.chatState !== 'idle' + + if (isRunning) { + setClosingTabId(sessionId) + return + } + + disconnectSession(sessionId) + closeTab(sessionId) + } + + const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { + e.preventDefault() + setContextMenu({ sessionId, x: e.clientX, y: e.clientY }) + } + + const handleCloseOthers = (sessionId: string) => { + setContextMenu(null) + const otherIds = tabs.filter((t) => t.sessionId !== sessionId).map((t) => t.sessionId) + for (const id of otherIds) { + disconnectSession(id) + closeTab(id) + } + } + + const handleCloseRight = (sessionId: string) => { + setContextMenu(null) + const idx = tabs.findIndex((t) => t.sessionId === sessionId) + const rightIds = tabs.slice(idx + 1).map((t) => t.sessionId) + for (const id of rightIds) { + disconnectSession(id) + closeTab(id) + } + } + + if (tabs.length === 0) return null + + return ( +
+ {canScrollLeft && ( + + )} + +
+ {tabs.map((tab) => ( + setActiveTab(tab.sessionId)} + onClose={() => handleClose(tab.sessionId)} + onContextMenu={(e) => handleContextMenu(e, tab.sessionId)} + /> + ))} +
+ + {canScrollRight && ( + + )} + + {contextMenu && ( +
+ + + +
+ )} + + {closingTabId && ( +
+
+

{t('tabs.closeConfirmTitle')}

+

{t('tabs.closeConfirmMessage')}

+
+ + + +
+
+
+ )} +
+ ) +} + +function TabItem({ tab, isActive, onClick, onClose, onContextMenu }: { + tab: Tab + isActive: boolean + onClick: () => void + onClose: () => void + onContextMenu: (e: React.MouseEvent) => void +}) { + return ( +
+ {tab.status === 'running' && ( + + )} + {tab.status === 'error' && ( + + )} + + + {tab.title || 'Untitled'} + + + +
+ ) +}