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 = (