feat(desktop): merge active tab into content surface, Chrome-style (#5314)

The active tab now shares the content card's fill and keyline: rounded
top corners, concave bottom flares (radial-gradient corner pieces whose
1px arc hands the tab border over to the card's top ring), and a
borderless base that runs into the card so the two read as one surface.
Inactive tabs sit flat on the shell with an inset hover pill and
hairline separators that hide around the active tab, Chrome-style.

Closes MUL-4439

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Jiayuan Zhang
2026-07-13 13:23:43 +08:00
committed by GitHub
parent bf288349f6
commit 8e1bf6cc51

View File

@@ -70,6 +70,18 @@ const TAB_ICONS: Record<string, LucideIcon> = {
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<string>;
@@ -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")}
>
<motion.div
className="relative size-full"
className="group/tab relative size-full"
initial={isEntering ? { opacity: 0, x: 8 } : false}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: isEntering ? 0.18 : 0, ease: TAB_ENTRY_EASE }}
onAnimationComplete={() => 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.
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0",
isDragging && "opacity-60",
)}
>
<span className="absolute inset-x-0 top-0 bottom-2.5 rounded-t-lg border border-b-0 border-surface-border bg-page-canvas" />
<span className="absolute inset-x-0 bottom-0 h-2.5 bg-page-canvas" />
<span
className="absolute bottom-0 size-2.5"
style={{ left: -TAB_FLARE_RADIUS + 1, background: tabFlareBackground("left") }}
/>
<span
className="absolute bottom-0 size-2.5"
style={{ right: -TAB_FLARE_RADIUS + 1, background: tabFlareBackground("right") }}
/>
</span>
) : (
<span
aria-hidden
className="pointer-events-none absolute inset-x-0.5 top-1 bottom-1 rounded-lg bg-sidebar-accent opacity-0 transition-opacity group-hover/tab:opacity-100"
/>
)}
{showSeparator && (
<span
aria-hidden
className="pointer-events-none absolute left-0 top-1/2 h-4 w-px -translate-y-1/2 bg-border"
/>
)}
<ContextMenu>
<ContextMenuTrigger render={tabButton} />
<ContextMenuContent>
@@ -338,7 +388,7 @@ function SortableTabItem({
{showAddedHighlight && (
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/10 ring-1 ring-inset ring-primary/20"
className="pointer-events-none absolute inset-x-0.5 top-1 bottom-1 rounded-lg bg-primary/10 ring-1 ring-inset ring-primary/20"
initial={{ opacity: shouldReduceMotion ? 0.25 : 0.65 }}
animate={{ opacity: 0 }}
transition={{ duration: shouldReduceMotion ? 0.16 : 0.42 }}
@@ -384,7 +434,7 @@ function NewTabEdgeFeedback({
key={`${signal.tabId}-${signal.sequence}`}
aria-hidden
data-new-tab-edge-feedback="true"
className="pointer-events-none absolute inset-y-2 right-0 z-20 w-8 rounded-r-md bg-gradient-to-l from-primary/35 via-primary/10 to-transparent"
className="pointer-events-none absolute top-4 bottom-1 right-0 z-20 w-8 rounded-r-lg bg-gradient-to-l from-primary/35 via-primary/10 to-transparent"
initial={{
opacity: shouldReduceMotion ? 0.45 : 0,
x: shouldReduceMotion ? 0 : 4,
@@ -434,7 +484,7 @@ function NewTabButton() {
aria-label="New tab"
title="New tab"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
className="mb-1 flex size-7 shrink-0 items-center justify-center self-end rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
>
<Plus className="size-3.5" />
</button>
@@ -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. */}
<div
ref={tabScrollRef}
data-tab-scroll-container
className="no-scrollbar flex h-full min-w-0 flex-1 items-center gap-0.5 overflow-x-auto overflow-y-hidden overscroll-x-contain"
className="no-scrollbar flex h-full min-w-0 flex-1 items-end overflow-x-auto overflow-y-hidden overscroll-x-contain px-4"
style={tabFadeStyle}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab, index) => (
<Fragment key={tab.id}>
<SortableTabItem
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
canCloseOthers={tabs.some(
(candidate) => candidate.id !== tab.id && !candidate.pinned,
)}
isNew={addedTabIdSet.has(tab.id)}
shouldReduceMotion={shouldReduceMotion}
/>
{tab.pinned &&
index === pinnedCount - 1 &&
unpinnedCount > 0 && (
<div
aria-hidden
className="mx-1 h-4 w-px shrink-0 bg-border"
/>
)}
</Fragment>
))}
{tabs.map((tab, index) => {
const previousTab = index > 0 ? tabs[index - 1] : null;
return (
<Fragment key={tab.id}>
<SortableTabItem
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
canCloseOthers={tabs.some(
(candidate) => 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 && (
<div
aria-hidden
className="mx-1 mb-2.5 h-4 w-px shrink-0 self-end bg-border"
/>
)}
</Fragment>
);
})}
</SortableContext>
</div>
</DndContext>