fix(desktop): animate overflowing tab additions

This commit is contained in:
Naiyuan Qing
2026-07-10 17:02:11 +08:00
parent cb87dd106b
commit e14b6b503d
6 changed files with 741 additions and 87 deletions

View File

@@ -122,7 +122,7 @@ function MainTopBar() {
transition={toolbarMotion}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div className="relative z-10 flex h-full items-center">
<div className="relative z-10 flex h-full min-w-0 max-w-full items-center">
<TabBar />
</div>
</motion.header>

View File

@@ -1,5 +1,12 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, fireEvent, within } from "@testing-library/react";
import { afterAll, describe, expect, it, vi, beforeEach } from "vitest";
import {
render,
renderHook,
fireEvent,
waitFor,
within,
} from "@testing-library/react";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
type MockTab = {
id: string;
@@ -22,6 +29,7 @@ const state = vi.hoisted(() => ({
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
togglePin: vi.fn<(tabId: string) => void>(),
closeTab: vi.fn<(tabId: string) => void>(),
closeOtherTabs: vi.fn<(tabId: string) => void>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
moveTab: vi.fn<(from: number, to: number) => void>(),
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
@@ -37,6 +45,7 @@ vi.mock("@/stores/tab-store", () => {
},
togglePin: state.togglePin,
closeTab: state.closeTab,
closeOtherTabs: state.closeOtherTabs,
setActiveTab: state.setActiveTab,
moveTab: state.moveTab,
addTab: state.addTab,
@@ -77,12 +86,35 @@ function reset() {
};
state.togglePin.mockReset();
state.closeTab.mockReset();
state.closeOtherTabs.mockReset();
state.setActiveTab.mockReset();
state.moveTab.mockReset();
state.addTab.mockReset();
}
beforeEach(reset);
beforeEach(() => {
reset();
vi.stubGlobal("matchMedia", (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
});
afterAll(() => vi.unstubAllGlobals());
describe("TabBar hover action buttons", () => {
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
@@ -149,3 +181,234 @@ describe("TabBar hover action buttons", () => {
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
});
});
describe("TabBar overflow", () => {
it("keeps tabs readable in a bounded horizontal scroller", () => {
state.byWorkspace.acme.tabs = Array.from({ length: 8 }, (_, index) => ({
id: `t${index}`,
path: `/acme/tab-${index}`,
title: `Tab ${index}`,
icon: "ListTodo",
pinned: index === 0,
}));
const { container, getByLabelText } = render(<TabBar />);
const tabBar = container.firstElementChild;
const tabScroller = container.querySelector("[data-tab-scroll-container]");
expect(tabBar).toHaveClass("min-w-0", "max-w-full");
expect(tabScroller).toHaveClass(
"min-w-0",
"no-scrollbar",
"overflow-x-auto",
"overflow-y-hidden",
);
expect(getByLabelText("Tab 1").closest("[data-tab-frame]")).toHaveClass(
"w-40",
"min-w-32",
);
const newTabButton = getByLabelText("New tab");
expect(tabScroller).not.toContainElement(newTabButton);
});
it("uses a directional mask instead of a visible scrollbar", async () => {
const tabScroller = document.createElement("div");
Object.defineProperties(tabScroller, {
clientWidth: { configurable: true, value: 320 },
scrollWidth: { configurable: true, value: 960 },
});
const tabScrollRef = { current: tabScroller };
const { result } = renderHook(() =>
useScrollFade(tabScrollRef, 24, "horizontal"),
);
tabScroller.scrollLeft = 0;
fireEvent.scroll(tabScroller);
await waitFor(() => {
expect(result.current?.maskImage).toBe(
"linear-gradient(to right, black 0%, black calc(100% - 24px), transparent 100%)",
);
});
tabScroller.scrollLeft = 240;
fireEvent.scroll(tabScroller);
await waitFor(() => {
expect(result.current?.maskImage).toBe(
"linear-gradient(to right, transparent 0%, black 24px, black calc(100% - 24px), transparent 100%)",
);
});
});
it("scrolls only the tab strip when the active tab moves out of view", () => {
state.byWorkspace.acme.tabs = Array.from({ length: 6 }, (_, index) => ({
id: `t${index}`,
path: `/acme/tab-${index}`,
title: `Tab ${index}`,
icon: "ListTodo",
pinned: false,
}));
state.byWorkspace.acme.activeTabId = "t0";
const { container, getByLabelText, rerender } = render(<TabBar />);
const tabScroller = container.querySelector(
"[data-tab-scroll-container]",
) as HTMLDivElement;
const lastTab = getByLabelText("Tab 5");
vi.spyOn(tabScroller, "getBoundingClientRect").mockReturnValue({
left: 100,
right: 420,
} as DOMRect);
vi.spyOn(lastTab, "getBoundingClientRect").mockReturnValue({
left: 450,
right: 578,
} as DOMRect);
Object.defineProperties(tabScroller, {
clientWidth: { configurable: true, value: 320 },
scrollWidth: { configurable: true, value: 960 },
});
tabScroller.scrollLeft = 40;
state.byWorkspace.acme.activeTabId = "t5";
rerender(<TabBar />);
expect(tabScroller.scrollLeft).toBe(222);
});
it("smoothly reveals a newly added active tab", () => {
state.byWorkspace.acme.tabs = Array.from({ length: 6 }, (_, index) => ({
id: `t${index}`,
path: `/acme/tab-${index}`,
title: `Tab ${index}`,
icon: "ListTodo",
pinned: false,
}));
state.byWorkspace.acme.activeTabId = "t0";
const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockImplementation(function (this: HTMLElement) {
if (this.matches("[data-tab-scroll-container]")) {
return { left: 100, right: 420 } as DOMRect;
}
if (this.matches('[data-tab-id="t6"]')) {
return { left: 450, right: 578 } as DOMRect;
}
return { left: 120, right: 248 } as DOMRect;
});
const { container, getByLabelText, rerender } = render(<TabBar />);
const tabScroller = container.querySelector(
"[data-tab-scroll-container]",
) as HTMLDivElement;
Object.defineProperties(tabScroller, {
clientWidth: { configurable: true, value: 320 },
scrollWidth: { configurable: true, value: 960 },
});
tabScroller.scrollLeft = 40;
const scrollTo = vi.fn(({ left }: ScrollToOptions) => {
if (typeof left === "number") tabScroller.scrollLeft = left;
});
Object.defineProperty(tabScroller, "scrollTo", {
configurable: true,
value: scrollTo,
});
state.byWorkspace.acme.tabs = [
...state.byWorkspace.acme.tabs,
{
id: "t6",
path: "/acme/tab-6",
title: "Tab 6",
icon: "ListTodo",
pinned: false,
},
];
state.byWorkspace.acme.activeTabId = "t6";
rerender(<TabBar />);
expect(getByLabelText("Tab 6")).toHaveAttribute(
"data-tab-entering",
"true",
);
expect(scrollTo).toHaveBeenCalledWith({ left: 222, behavior: "smooth" });
rectSpy.mockRestore();
});
it("keeps background additions offscreen and acknowledges them at the edge", () => {
state.byWorkspace.acme.tabs = Array.from({ length: 6 }, (_, index) => ({
id: `t${index}`,
path: `/acme/tab-${index}`,
title: `Tab ${index}`,
icon: "ListTodo",
pinned: false,
}));
state.byWorkspace.acme.activeTabId = "t0";
const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockImplementation(function (this: HTMLElement) {
if (this.matches("[data-tab-scroll-container]")) {
return { left: 100, right: 420 } as DOMRect;
}
if (this.matches('[data-tab-id="t6"]')) {
return { left: 450, right: 578 } as DOMRect;
}
return { left: 120, right: 248 } as DOMRect;
});
const { container, rerender } = render(<TabBar />);
const tabScroller = container.querySelector(
"[data-tab-scroll-container]",
) as HTMLDivElement;
Object.defineProperties(tabScroller, {
clientWidth: { configurable: true, value: 320 },
scrollWidth: { configurable: true, value: 960 },
});
tabScroller.scrollLeft = 40;
const scrollTo = vi.fn();
Object.defineProperty(tabScroller, "scrollTo", {
configurable: true,
value: scrollTo,
});
state.byWorkspace.acme.tabs = [
...state.byWorkspace.acme.tabs,
{
id: "t6",
path: "/acme/tab-6",
title: "Tab 6",
icon: "ListTodo",
pinned: false,
},
];
rerender(<TabBar />);
expect(tabScroller.scrollLeft).toBe(40);
expect(scrollTo).not.toHaveBeenCalled();
expect(
container.querySelector('[data-new-tab-edge-feedback="true"]'),
).toBeInTheDocument();
rectSpy.mockRestore();
});
});
describe("TabBar context menu", () => {
it("closes other tabs from the context menu", async () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
{ id: "tC", path: "/acme/agents", title: "Agents", icon: "Bot", pinned: false },
];
const { findByText, getByLabelText } = render(<TabBar />);
fireEvent.contextMenu(getByLabelText("Projects"));
fireEvent.click(await findByText("Close other tabs"));
expect(state.closeOtherTabs).toHaveBeenCalledWith("tB");
});
});

View File

@@ -1,4 +1,12 @@
import { Fragment } from "react";
import {
Fragment,
useEffect,
useLayoutEffect,
useRef,
useState,
type RefObject,
} from "react";
import { motion, useReducedMotion } from "motion/react";
import {
Inbox,
CircleUser,
@@ -11,6 +19,7 @@ import {
Plus,
Pin,
PinOff,
ListX,
type LucideIcon,
} from "lucide-react";
import {
@@ -38,6 +47,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@multica/ui/components/ui/context-menu";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { cn } from "@multica/ui/lib/utils";
import {
useTabStore,
@@ -57,10 +67,97 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Settings,
};
const TAB_SCROLL_FADE_SIZE = 24;
const TAB_ENTRY_EASE = [0.22, 1, 0.36, 1] as const;
type TabSnapshot = {
workspaceSlug: string | null;
ids: Set<string>;
};
function getAddedTabIds(
previous: TabSnapshot | null,
workspaceSlug: string | null,
currentIds: string[],
) {
if (
!previous ||
previous.workspaceSlug !== workspaceSlug ||
currentIds.length <= previous.ids.size
) {
return [];
}
return currentIds.filter((id) => !previous.ids.has(id));
}
function getTabElement(
scroller: HTMLDivElement,
tabId?: string,
): HTMLElement | null {
if (!tabId) {
return scroller.querySelector<HTMLElement>('[data-tab-active="true"]');
}
return Array.from(
scroller.querySelectorAll<HTMLElement>("[data-tab-id]"),
).find((candidate) => candidate.dataset.tabId === tabId) ?? null;
}
function getTabScrollTarget(
scroller: HTMLDivElement,
tab: HTMLElement,
): number {
const scrollerRect = scroller.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
const hasHiddenLeft = scroller.scrollLeft > 1;
const hasHiddenRight = scroller.scrollLeft < maxScrollLeft - 1;
const visibleLeft =
scrollerRect.left + (hasHiddenLeft ? TAB_SCROLL_FADE_SIZE : 0);
const visibleRight =
scrollerRect.right - (hasHiddenRight ? TAB_SCROLL_FADE_SIZE : 0);
if (tabRect.left < visibleLeft) {
return Math.max(0, scroller.scrollLeft - (visibleLeft - tabRect.left));
}
if (tabRect.right > visibleRight) {
return Math.min(
maxScrollLeft,
scroller.scrollLeft + (tabRect.right - visibleRight),
);
}
return scroller.scrollLeft;
}
// Keep scrolling scoped to the strip. Native scrollIntoView can also move
// scrollable desktop-shell ancestors and displace the whole window chrome.
function keepTabVisible(
scroller: HTMLDivElement | null,
tabId?: string,
behavior: ScrollBehavior = "auto",
) {
if (!scroller) return;
const tab = getTabElement(scroller, tabId);
if (!tab) return;
const target = getTabScrollTarget(scroller, tab);
if (Math.abs(target - scroller.scrollLeft) <= 1) return;
if (behavior === "smooth" && typeof scroller.scrollTo === "function") {
scroller.scrollTo({ left: target, behavior: "smooth" });
return;
}
scroller.scrollLeft = target;
}
function SortableTabItem({
tab,
isActive,
isOnly,
canCloseOthers,
isNew,
shouldReduceMotion,
}: {
tab: Tab;
isActive: boolean;
@@ -70,9 +167,13 @@ function SortableTabItem({
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
*/
isOnly: boolean;
canCloseOthers: boolean;
isNew: boolean;
shouldReduceMotion: boolean;
}) {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const closeTab = useTabStore((s) => s.closeTab);
const closeOtherTabs = useTabStore((s) => s.closeOtherTabs);
const togglePin = useTabStore((s) => s.togglePin);
const {
@@ -121,19 +222,28 @@ function SortableTabItem({
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
// reachable via the hover action button below and the right-click menu.
const showCloseButton = !tab.pinned && !isOnly;
const [isEntering, setIsEntering] = useState(isNew && !shouldReduceMotion);
const [showAddedHighlight, setShowAddedHighlight] = useState(isNew);
useEffect(() => {
if (!isDragging) return;
setIsEntering(false);
setShowAddedHighlight(false);
}, [isDragging]);
const tabButton = (
<button
type="button"
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
data-tab-active={isActive ? "true" : undefined}
data-tab-entering={isEntering ? "true" : undefined}
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className={cn(
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"group flex size-full min-w-0 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"select-none cursor-default",
isActive
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
@@ -176,33 +286,130 @@ function SortableTabItem({
);
return (
<ContextMenu>
<ContextMenuTrigger render={tabButton} />
<ContextMenuContent>
<ContextMenuItem onClick={() => togglePin(tab.id)}>
{tab.pinned ? (
<>
<PinOff />
Unpin tab
</>
) : (
<>
<Pin />
Pin tab
</>
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={tab.pinned || isOnly}
onClick={() => closeTab(tab.id)}
>
<X />
Close tab
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<div
ref={setNodeRef}
style={style}
data-tab-frame
data-tab-id={tab.id}
className="h-7 w-40 min-w-32"
>
<motion.div
className="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)}
>
<ContextMenu>
<ContextMenuTrigger render={tabButton} />
<ContextMenuContent>
<ContextMenuItem onClick={() => togglePin(tab.id)}>
{tab.pinned ? (
<>
<PinOff />
Unpin tab
</>
) : (
<>
<Pin />
Pin tab
</>
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={tab.pinned || isOnly}
onClick={() => closeTab(tab.id)}
>
<X />
Close tab
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
disabled={!canCloseOthers}
onClick={() => closeOtherTabs(tab.id)}
>
<ListX />
Close other tabs
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{showAddedHighlight && (
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md 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 }}
onAnimationComplete={() => setShowAddedHighlight(false)}
/>
)}
</motion.div>
</div>
);
}
function NewTabEdgeFeedback({
newTabId,
scrollerRef,
shouldReduceMotion,
}: {
newTabId: string | null;
scrollerRef: RefObject<HTMLDivElement | null>;
shouldReduceMotion: boolean;
}) {
const sequenceRef = useRef(0);
const [signal, setSignal] = useState<{
tabId: string;
sequence: number;
} | null>(null);
useLayoutEffect(() => {
const scroller = scrollerRef.current;
if (!scroller || !newTabId) return;
const tab = getTabElement(scroller, newTabId);
if (!tab || getTabScrollTarget(scroller, tab) === scroller.scrollLeft) {
return;
}
sequenceRef.current += 1;
setSignal({ tabId: newTabId, sequence: sequenceRef.current });
}, [newTabId, scrollerRef]);
if (!signal) return null;
return (
<motion.div
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"
initial={{
opacity: shouldReduceMotion ? 0.45 : 0,
x: shouldReduceMotion ? 0 : 4,
}}
animate={{
opacity: shouldReduceMotion ? [0.45, 0] : [0, 0.8, 0],
x: 0,
}}
transition={{
opacity: {
duration: shouldReduceMotion ? 0.2 : 0.45,
times: shouldReduceMotion ? [0, 1] : [0, 0.18, 1],
ease: "easeOut",
},
x: {
duration: shouldReduceMotion ? 0 : 0.18,
ease: TAB_ENTRY_EASE,
},
}}
onAnimationComplete={() => {
setSignal((current) =>
current?.sequence === signal.sequence ? null : current,
);
}}
/>
);
}
@@ -224,6 +431,8 @@ function NewTabButton() {
<button
type="button"
onClick={handleClick}
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"
>
@@ -235,6 +444,15 @@ function NewTabButton() {
export function TabBar() {
const group = useActiveGroup();
const moveTab = useTabStore((s) => s.moveTab);
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
const shouldReduceMotion = useReducedMotion() ?? false;
const tabScrollRef = useRef<HTMLDivElement>(null);
const previousTabsRef = useRef<TabSnapshot | null>(null);
const tabFadeStyle = useScrollFade(
tabScrollRef,
TAB_SCROLL_FADE_SIZE,
"horizontal",
);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -245,9 +463,58 @@ export function TabBar() {
const tabs = group?.tabs ?? [];
const activeTabId = group?.activeTabId ?? "";
const tabIds = tabs.map((t) => t.id);
const tabOrder = tabIds.join("\0");
const addedTabIds = getAddedTabIds(
previousTabsRef.current,
activeWorkspaceSlug,
tabIds,
);
const addedTabIdSet = new Set(addedTabIds);
const newestTabId = addedTabIds.at(-1) ?? null;
const backgroundAddedTabId =
newestTabId && newestTabId !== activeTabId ? newestTabId : null;
const pinnedCount = tabs.filter((t) => t.pinned).length;
const unpinnedCount = tabs.length - pinnedCount;
useLayoutEffect(() => {
const currentTabIds = tabOrder ? tabOrder.split("\0") : [];
const newlyAddedIds = getAddedTabIds(
previousTabsRef.current,
activeWorkspaceSlug,
currentTabIds,
);
previousTabsRef.current = {
workspaceSlug: activeWorkspaceSlug,
ids: new Set(currentTabIds),
};
if (newlyAddedIds.length > 0) {
if (newlyAddedIds.includes(activeTabId)) {
keepTabVisible(
tabScrollRef.current,
activeTabId,
shouldReduceMotion ? "auto" : "smooth",
);
}
// Background additions intentionally preserve the user's current tab and
// scroll position. NewTabEdgeFeedback handles the offscreen acknowledgement.
return;
}
keepTabVisible(tabScrollRef.current);
}, [activeWorkspaceSlug, activeTabId, shouldReduceMotion, tabOrder]);
useEffect(() => {
const scroller = tabScrollRef.current;
if (!scroller || typeof ResizeObserver === "undefined") return;
// Sidebar and window resizing can hide the active tab without changing
// activeTabId, so keep it visible as the strip's viewport changes.
const resizeObserver = new ResizeObserver(() => keepTabVisible(scroller));
resizeObserver.observe(scroller);
return () => resizeObserver.disconnect();
}, []);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
@@ -260,33 +527,52 @@ export function TabBar() {
};
return (
<div className="flex h-full items-center gap-0.5 px-2 justify-start">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis, restrictToParentElement]}
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab, index) => (
<Fragment key={tab.id}>
<SortableTabItem
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
{tab.pinned &&
index === pinnedCount - 1 &&
unpinnedCount > 0 && (
<div
aria-hidden
className="mx-1 h-4 w-px bg-border"
<div className="flex h-full w-full min-w-0 max-w-full items-center justify-start gap-0.5 px-2">
<div className="relative flex h-full min-w-0 flex-1 items-center">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis, restrictToParentElement]}
onDragEnd={handleDragEnd}
>
<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"
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}
/>
)}
</Fragment>
))}
</SortableContext>
</DndContext>
{tab.pinned &&
index === pinnedCount - 1 &&
unpinnedCount > 0 && (
<div
aria-hidden
className="mx-1 h-4 w-px shrink-0 bg-border"
/>
)}
</Fragment>
))}
</SortableContext>
</div>
</DndContext>
<NewTabEdgeFeedback
newTabId={backgroundAddedTabId}
scrollerRef={tabScrollRef}
shouldReduceMotion={shouldReduceMotion}
/>
</div>
{group && <NewTabButton />}
</div>
);

View File

@@ -320,6 +320,59 @@ describe("useTabStore actions", () => {
});
});
describe("bulk tab closing", () => {
it("closes other unpinned tabs, preserves pinned tabs, and activates the target", () => {
vi.useFakeTimers();
try {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const projectsId = store.addTab("/acme/projects", "Projects", "FolderKanban");
const agentsId = store.addTab("/acme/agents", "Agents", "Bot");
const settingsId = store.addTab("/acme/settings", "Settings", "Settings");
store.togglePin(issuesId);
store.setActiveTab(agentsId);
const before = useTabStore.getState().byWorkspace.acme.tabs;
const agentsDispose = vi.mocked(before.find((tab) => tab.id === agentsId)!.router.dispose);
const settingsDispose = vi.mocked(
before.find((tab) => tab.id === settingsId)!.router.dispose,
);
store.closeOtherTabs(projectsId);
const group = useTabStore.getState().byWorkspace.acme;
expect(group.tabs.map((tab) => tab.id)).toEqual([issuesId, projectsId]);
expect(group.activeTabId).toBe(projectsId);
expect(agentsDispose).not.toHaveBeenCalled();
expect(settingsDispose).not.toHaveBeenCalled();
vi.runAllTimers();
expect(agentsDispose).toHaveBeenCalledOnce();
expect(settingsDispose).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("keeps a surviving active tab when closing other tabs", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const projectsId = store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
store.addTab("/acme/settings", "Settings", "Settings");
store.togglePin(issuesId);
store.setActiveTab(issuesId);
store.closeOtherTabs(projectsId);
const group = useTabStore.getState().byWorkspace.acme;
expect(group.tabs.map((tab) => tab.id)).toEqual([issuesId, projectsId]);
expect(group.activeTabId).toBe(issuesId);
});
});
describe("togglePin", () => {
it("flips a tab's pinned state", () => {
const store = useTabStore.getState();

View File

@@ -76,6 +76,8 @@ interface TabStore {
* "every live workspace has at least one tab" holds.
*/
closeTab: (tabId: string) => void;
/** Close every other unpinned tab in the target tab's workspace. */
closeOtherTabs: (tabId: string) => void;
/**
* Activate a tab. Finds it across all workspaces. Sets both the owning
* workspace as active and that group's activeTabId; needed for any code
@@ -257,6 +259,44 @@ function findTabLocation(
return null;
}
function buildCloseOtherTabsResult(
byWorkspace: Record<string, WorkspaceTabGroup>,
tabId: string,
): {
nextByWorkspace: Record<string, WorkspaceTabGroup>;
closingTabs: Tab[];
} | null {
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return null;
const { slug, group } = hit;
const closingTabs = group.tabs.filter(
(tab) => !tab.pinned && tab.id !== tabId,
);
if (closingTabs.length === 0) return null;
const closingIds = new Set(closingTabs.map((tab) => tab.id));
const nextTabs = group.tabs.filter((tab) => !closingIds.has(tab.id));
const nextActiveTabId = closingIds.has(group.activeTabId)
? tabId
: group.activeTabId;
return {
nextByWorkspace: {
...byWorkspace,
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
closingTabs,
};
}
function disposeTabRoutersAfterUnmount(tabs: readonly Tab[]) {
if (tabs.length === 0) return;
// Let React unmount every closed tab's RouterProvider before disposal.
window.setTimeout(() => {
for (const tab of tabs) tab.router.dispose();
}, 0);
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
@@ -388,10 +428,6 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const closing = group.tabs[index];
const disposeClosingRouter = () => {
// Let React unmount the tab's RouterProvider before disposing it.
window.setTimeout(() => closing.router.dispose(), 0);
};
if (group.tabs.length === 1) {
// Last tab in this workspace — reseed a default so the workspace
@@ -404,7 +440,7 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: [fresh], activeTabId: fresh.id },
},
});
disposeClosingRouter();
disposeTabRoutersAfterUnmount([closing]);
return;
}
@@ -420,7 +456,15 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
disposeClosingRouter();
disposeTabRoutersAfterUnmount([closing]);
},
closeOtherTabs(tabId) {
const { byWorkspace } = get();
const result = buildCloseOtherTabsResult(byWorkspace, tabId);
if (!result) return;
set({ byWorkspace: result.nextByWorkspace });
disposeTabRoutersAfterUnmount(result.closingTabs);
},
setActiveTab(tabId) {

View File

@@ -1,38 +1,43 @@
import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react";
export type ScrollFadeAxis = "vertical" | "horizontal";
/**
* Returns a dynamic maskImage style based on scroll position.
* - At top → fade bottom only
* - At bottom → fade top only
* - At start → fade end only
* - At end → fade start only
* - In middle → fade both
* - No overflow → undefined (no mask)
*/
export function useScrollFade(
ref: RefObject<HTMLElement | null>,
fadeSize = 32
fadeSize = 32,
axis: ScrollFadeAxis = "vertical",
): CSSProperties | undefined {
const [fade, setFade] = useState<"none" | "top" | "bottom" | "both">("none");
const [fade, setFade] = useState<"none" | "start" | "end" | "both">("none");
const update = useCallback(() => {
const el = ref.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const scrollable = scrollHeight - clientHeight;
const position = axis === "horizontal" ? el.scrollLeft : el.scrollTop;
const scrollSize = axis === "horizontal" ? el.scrollWidth : el.scrollHeight;
const clientSize = axis === "horizontal" ? el.clientWidth : el.clientHeight;
const scrollable = scrollSize - clientSize;
if (scrollable <= 0) {
setFade("none");
return;
}
const atTop = scrollTop <= 1;
const atBottom = scrollTop >= scrollable - 1;
const atStart = position <= 1;
const atEnd = position >= scrollable - 1;
if (atTop && atBottom) setFade("none");
else if (atTop) setFade("bottom");
else if (atBottom) setFade("top");
if (atStart && atEnd) setFade("none");
else if (atStart) setFade("end");
else if (atEnd) setFade("start");
else setFade("both");
}, [ref]);
}, [axis, ref]);
useEffect(() => {
const el = ref.current;
@@ -44,10 +49,9 @@ export function useScrollFade(
const ro = new ResizeObserver(update);
ro.observe(el);
// ResizeObserver only fires on the container's own box. When children
// grow inside a flex/auto-height parent (e.g. async-loaded list items,
// collapsibles), scrollHeight changes but clientHeight does not — the
// mask would stay "none" until the user scrolls. MutationObserver on
// childList catches those content insertions.
// grow inside a flex/auto-sized parent, the scroll extent can change while
// the viewport does not — the mask would stay "none" until the user scrolls.
// MutationObserver on childList catches those content insertions.
const mo = new MutationObserver(update);
mo.observe(el, { childList: true, subtree: true });
@@ -61,13 +65,17 @@ export function useScrollFade(
if (fade === "none") return undefined;
const top = fade === "top" || fade === "both" ? `transparent 0%, black ${fadeSize}px` : "black 0%";
const bottom =
fade === "bottom" || fade === "both"
const start =
fade === "start" || fade === "both"
? `transparent 0%, black ${fadeSize}px`
: "black 0%";
const end =
fade === "end" || fade === "both"
? `black calc(100% - ${fadeSize}px), transparent 100%`
: "black 100%";
const gradient = `linear-gradient(to bottom, ${top}, ${bottom})`;
const direction = axis === "horizontal" ? "right" : "bottom";
const gradient = `linear-gradient(to ${direction}, ${start}, ${end})`;
return {
maskImage: gradient,