diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx
index b711c471ad..d1b30a4d80 100644
--- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx
+++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx
@@ -122,7 +122,7 @@ function MainTopBar() {
transition={toolbarMotion}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
-
+
diff --git a/apps/desktop/src/renderer/src/components/tab-bar.test.tsx b/apps/desktop/src/renderer/src/components/tab-bar.test.tsx
index f1b59d026c..5463b6c6fe 100644
--- a/apps/desktop/src/renderer/src/components/tab-bar.test.tsx
+++ b/apps/desktop/src/renderer/src/components/tab-bar.test.tsx
@@ -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
,
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();
+ 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();
+ 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();
+
+ 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();
+ 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();
+
+ 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();
+ 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();
+
+ 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();
+ fireEvent.contextMenu(getByLabelText("Projects"));
+ fireEvent.click(await findByText("Close other tabs"));
+
+ expect(state.closeOtherTabs).toHaveBeenCalledWith("tB");
+ });
+
+});
diff --git a/apps/desktop/src/renderer/src/components/tab-bar.tsx b/apps/desktop/src/renderer/src/components/tab-bar.tsx
index f002d26fcf..c7e09b24fb 100644
--- a/apps/desktop/src/renderer/src/components/tab-bar.tsx
+++ b/apps/desktop/src/renderer/src/components/tab-bar.tsx
@@ -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 = {
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;
+};
+
+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('[data-tab-active="true"]');
+ }
+
+ return Array.from(
+ scroller.querySelectorAll("[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 = (