diff --git a/apps/desktop/src/renderer/src/components/tab-bar.tsx b/apps/desktop/src/renderer/src/components/tab-bar.tsx index d5ff7c3374..5c90840a0f 100644 --- a/apps/desktop/src/renderer/src/components/tab-bar.tsx +++ b/apps/desktop/src/renderer/src/components/tab-bar.tsx @@ -70,6 +70,18 @@ const TAB_ICONS: Record = { const TAB_SCROLL_FADE_SIZE = 24; const TAB_ENTRY_EASE = [0.22, 1, 0.36, 1] as const; +// Chrome-style merged tab: the active tab shares the content surface's fill +// and flares into it through concave bottom corners. Each flare is a small +// square whose radial gradient carves a quarter-circle notch (shell shows +// through), strokes a 1px arc that continues the tab's side border into the +// content card's top ring, and fills the rest with the surface color. The +// 0.4px stop spread anti-aliases the arc. +const TAB_FLARE_RADIUS = 10; +const tabFlareBackground = (side: "left" | "right") => { + const r = TAB_FLARE_RADIUS; + return `radial-gradient(circle at top ${side}, transparent ${r - 1.2}px, var(--surface-border) ${r - 0.8}px, var(--surface-border) ${r - 0.2}px, var(--page-canvas) ${r + 0.2}px)`; +}; + type TabSnapshot = { workspaceSlug: string | null; ids: Set; @@ -158,6 +170,7 @@ function SortableTabItem({ canCloseOthers, isNew, shouldReduceMotion, + showSeparator, }: { tab: Tab; isActive: boolean; @@ -170,6 +183,8 @@ function SortableTabItem({ canCloseOthers: boolean; isNew: boolean; shouldReduceMotion: boolean; + /** Hairline on the tab's left edge — hidden next to the active tab. */ + showSeparator: boolean; }) { const setActiveTab = useTabStore((s) => s.setActiveTab); const closeTab = useTabStore((s) => s.closeTab); @@ -195,7 +210,7 @@ function SortableTabItem({ transform: CSS.Transform.toString(transform), transition, WebkitAppRegion: "no-drag", - zIndex: isDragging ? 10 : undefined, + zIndex: isDragging ? 20 : undefined, } as React.CSSProperties; const handleClick = () => { @@ -243,11 +258,11 @@ function SortableTabItem({ title={tab.pinned ? `${tab.title} (pinned)` : undefined} style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className={cn( - "group flex size-full min-w-0 items-center gap-1.5 rounded-md px-2 text-xs transition-colors", + "group relative flex size-full min-w-0 items-center gap-1.5 px-2.5 text-xs transition-colors", "select-none cursor-default", isActive - ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" - : "bg-sidebar-accent/50 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + ? "font-medium text-foreground" + : "text-muted-foreground hover:text-sidebar-accent-foreground", isDragging && "opacity-60", )} > @@ -291,15 +306,50 @@ function SortableTabItem({ style={style} data-tab-frame data-tab-id={tab.id} - className="h-7 w-40 min-w-32" + className={cn("h-9 w-40 min-w-32", isActive && "z-10")} > setIsEntering(false)} > + {isActive ? ( + // Merged-tab chrome: a bordered cap, a borderless base whose fill + // runs into the content card below (covering its top ring), and two + // flares whose arcs hand the keyline over to the card's ring. The + // flares overlap the tab edge by 1px so arc and side border meet. + + + + + + + ) : ( + + )} + {showSeparator && ( + + )} @@ -338,7 +388,7 @@ function SortableTabItem({ {showAddedHighlight && ( @@ -544,35 +594,48 @@ export function TabBar() { modifiers={[restrictToHorizontalAxis, restrictToParentElement]} onDragEnd={handleDragEnd} > + {/* px-4 keeps the active tab's flares inside the scroller's clip + (overflow clips at the padding box) and clears the content + card's rounded top-left corner below the first tab. */}
- {tabs.map((tab, index) => ( - - candidate.id !== tab.id && !candidate.pinned, - )} - isNew={addedTabIdSet.has(tab.id)} - shouldReduceMotion={shouldReduceMotion} - /> - {tab.pinned && - index === pinnedCount - 1 && - unpinnedCount > 0 && ( -
- )} - - ))} + {tabs.map((tab, index) => { + const previousTab = index > 0 ? tabs[index - 1] : null; + return ( + + candidate.id !== tab.id && !candidate.pinned, + )} + isNew={addedTabIdSet.has(tab.id)} + shouldReduceMotion={shouldReduceMotion} + showSeparator={ + !!previousTab && + tab.id !== activeTabId && + previousTab.id !== activeTabId && + // the pinned-zone divider already separates this pair + !(previousTab.pinned && !tab.pinned) + } + /> + {tab.pinned && + index === pinnedCount - 1 && + unpinnedCount > 0 && ( +
+ )} + + ); + })}