diff --git a/src/components/layout/tokens/viewContainerTokens.ts b/src/components/layout/tokens/viewContainerTokens.ts index 2e1a180bc7..ceb77d1c29 100644 --- a/src/components/layout/tokens/viewContainerTokens.ts +++ b/src/components/layout/tokens/viewContainerTokens.ts @@ -32,9 +32,11 @@ export const PANE_WIDTH_TRANSITION_CLASSES = * `PANE_WIDTH_TRANSITION_CLASSES` to move, so the inset must travel on the * same curve or the header's titles jump by the reservation and then glide. * - * Station and chat headers apply it through `useStationToggleInsetTransition`, - * which limits it to a station opening or closing; tab changes inside a - * station must not animate the inset. + * Station and chat headers apply it through `usePaneLayoutInsetTransition`, + * which limits it to the toggles that actually move a pane — the sidebar, + * the chat pane, a station opening or closing. Reservation changes that move + * nothing (a tab opening, a tab or session switch) must not animate the + * inset, or header controls glide on every tab change. */ export const CHROME_INSET_TRANSITION_CLASSES = "transition-[padding] duration-200 ease-out motion-reduce:transition-none"; diff --git a/src/engines/ChatPanel/ChatPanelHeader.tsx b/src/engines/ChatPanel/ChatPanelHeader.tsx index 83c275cd63..ce4d5a2ccf 100644 --- a/src/engines/ChatPanel/ChatPanelHeader.tsx +++ b/src/engines/ChatPanel/ChatPanelHeader.tsx @@ -16,7 +16,7 @@ import { HugeiconsIcon, SquareTerminalIcon, } from "@src/icons"; -import { useStationToggleInsetTransition } from "@src/scaffold/AppLayout/useStationToggleInsetTransition"; +import { usePaneLayoutInsetTransition } from "@src/scaffold/AppLayout/usePaneLayoutInsetTransition"; import { CollapsedSidebarButton } from "@src/scaffold/NavigationSidebar/CollapsedSidebarButton"; import { ChatPaneFocusButton } from "@src/scaffold/WorkbenchChrome/StationPaneControls"; import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanel/displayPrefsAtoms"; @@ -169,7 +169,7 @@ export function ChatPanelHeader({ const trailingInsetPx = pinnedChromeInThisHeader ? rightEdge.reservedRight : undefined; - const insetTransitionClassName = useStationToggleInsetTransition(); + const insetTransitionClassName = usePaneLayoutInsetTransition(); if (!showHeader) return null; const tuiModeLabel = tuiMode ? t("chat.tuiModeOn") : t("chat.tuiModeOff"); diff --git a/src/modules/WorkStation/shared/TabBar/index.tsx b/src/modules/WorkStation/shared/TabBar/index.tsx index e9c90e5a27..9fe77026e6 100644 --- a/src/modules/WorkStation/shared/TabBar/index.tsx +++ b/src/modules/WorkStation/shared/TabBar/index.tsx @@ -51,7 +51,7 @@ import { import { useWorkbenchRightEdgeReservation } from "@src/hooks/ui/workbench/usePinnedWorkbenchChrome"; import { requestTeamInboxSessionHandoffAtom } from "@src/modules/MainApp/TeamInbox/store"; import { useActionSystemOptional } from "@src/scaffold/ActionSystem"; -import { useStationToggleInsetTransition } from "@src/scaffold/AppLayout/useStationToggleInsetTransition"; +import { usePaneLayoutInsetTransition } from "@src/scaffold/AppLayout/usePaneLayoutInsetTransition"; import { CollapsedSidebarButton } from "@src/scaffold/NavigationSidebar/CollapsedSidebarButton"; import { openTeamInboxInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabOpen/integrations"; import { @@ -225,7 +225,7 @@ export const TabBar: React.FC = memo( // macOS pins the right-edge collapse toggles in window space; make room // whenever the workstation is the pane touching that edge. const rightEdge = useWorkbenchRightEdgeReservation(); - const insetTransitionClassName = useStationToggleInsetTransition(); + const insetTransitionClassName = usePaneLayoutInsetTransition(); const scrollReveal = useAtomValue(tabScrollRevealAtom); const gitStatusMap = useAtomValue(gitFileStatusMapAtom); diff --git a/src/scaffold/AppLayout/usePaneLayoutInsetTransition.test.ts b/src/scaffold/AppLayout/usePaneLayoutInsetTransition.test.ts new file mode 100644 index 0000000000..8d62731ada --- /dev/null +++ b/src/scaffold/AppLayout/usePaneLayoutInsetTransition.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { CHROME_INSET_TRANSITION_CLASSES } from "@src/components/layout/tokens/viewContainerTokens"; +import { useShouldOffsetChatPanelHeader } from "@src/hooks/ui/sidebar/useCollapsedSidebarChromeOffset"; +import { chatPanelMaximizedAtom } from "@src/store/ui/chatPanel/surfaceAtoms"; +import { chatWidthAtom } from "@src/store/ui/chatPanel/widthAtoms"; +import { sidebarCollapsedAtom } from "@src/store/ui/sidebarAtom"; + +import { + PANE_LAYOUT_INSET_TRANSITION_MS, + usePaneLayoutInsetTransition, +} from "./usePaneLayoutInsetTransition"; + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +/** Stands in for the chat pane's header row: same inset, same transition. */ +function HeaderProbe() { + const offset = useShouldOffsetChatPanelHeader({ + position: "left", + useExternalWidth: false, + }); + const className = usePaneLayoutInsetTransition(); + return createElement("div", { + "data-testid": "header-probe", + "data-inset": offset ? "reserved" : "none", + className, + }); +} + +/** Stands in for a header row; `activeTabId` changes like a tab switch would. */ +function InsetProbe({ activeTabId }: { activeTabId: string }) { + const className = usePaneLayoutInsetTransition(); + return createElement("div", { + "data-testid": "inset-probe", + "data-active-tab": activeTabId, + className, + }); +} + +describe("usePaneLayoutInsetTransition", () => { + let container: HTMLDivElement; + let root: Root; + let store: ReturnType; + + beforeAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + vi.useFakeTimers(); + // Pane layout is persisted; start every test with the station open, the + // sidebar expanded and the chat pane showing. + localStorage.clear(); + store = createStore(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + afterAll(() => { + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function render(activeTabId = "tab-a"): void { + act(() => { + root.render( + createElement( + Provider, + { store }, + createElement(InsetProbe, { activeTabId }) + ) + ); + }); + } + + function probeClassName(): string | null { + return container + .querySelector('[data-testid="inset-probe"]') + ?.getAttribute("class") as string | null; + } + + function advance(ms: number): void { + act(() => { + vi.advanceTimersByTime(ms); + }); + } + + /** Every pane toggle animates the inset for exactly one pane transition. */ + function expectTogglesInsetTransition(toggle: () => void): void { + act(toggle); + expect(probeClassName()).toBe(CHROME_INSET_TRANSITION_CLASSES); + advance(PANE_LAYOUT_INSET_TRANSITION_MS); + expect(probeClassName()).toBe(""); + } + + it("keeps insets still at rest and when tabs change inside the station", () => { + render("tab-a"); + expect(probeClassName()).toBe(""); + + render("tab-b"); + expect(probeClassName()).toBe(""); + }); + + it("animates insets while the station closes and opens, then stops", () => { + render(); + + expectTogglesInsetTransition(() => store.set(chatPanelMaximizedAtom, true)); + expectTogglesInsetTransition(() => + store.set(chatPanelMaximizedAtom, false) + ); + }); + + it("animates insets while the sidebar collapses and expands", () => { + render(); + + expectTogglesInsetTransition(() => store.set(sidebarCollapsedAtom, true)); + expectTogglesInsetTransition(() => store.set(sidebarCollapsedAtom, false)); + }); + + it("animates insets while the chat pane goes away and comes back", () => { + render(); + + expectTogglesInsetTransition(() => store.set(chatWidthAtom, 0)); + expectTogglesInsetTransition(() => store.set(chatWidthAtom, 520)); + }); + + it("ignores a chat-pane resize that leaves the pane on screen", () => { + render(); + + act(() => store.set(chatWidthAtom, 360)); + expect(probeClassName()).toBe(""); + }); + + it("does not let an earlier toggle cut a quick reopen short", () => { + render(); + + act(() => store.set(chatPanelMaximizedAtom, true)); + advance(PANE_LAYOUT_INSET_TRANSITION_MS - 100); + act(() => store.set(chatPanelMaximizedAtom, false)); + + // The first toggle's timer would have fired here. + advance(100); + expect(probeClassName()).toBe(CHROME_INSET_TRANSITION_CLASSES); + + advance(PANE_LAYOUT_INSET_TRANSITION_MS - 100); + expect(probeClassName()).toBe(""); + }); + + it("does not let one pane's toggle cut another pane's short", () => { + render(); + + act(() => store.set(sidebarCollapsedAtom, true)); + advance(PANE_LAYOUT_INSET_TRANSITION_MS - 100); + act(() => store.set(chatPanelMaximizedAtom, true)); + + advance(100); + expect(probeClassName()).toBe(CHROME_INSET_TRANSITION_CLASSES); + + advance(PANE_LAYOUT_INSET_TRANSITION_MS - 100); + expect(probeClassName()).toBe(""); + }); + + it("turns the transition on in the render that flips a header's inset", () => { + act(() => { + root.render( + createElement(Provider, { store }, createElement(HeaderProbe)) + ); + }); + const probe = () => + container.querySelector('[data-testid="header-probe"]') as HTMLDivElement; + expect(probe().dataset.inset).toBe("none"); + expect(probe().className).toBe(""); + + // A CSS transition takes its timing from the after-change style, so the + // class has to be there the first time the reserved inset is. + act(() => store.set(sidebarCollapsedAtom, true)); + expect(probe().dataset.inset).toBe("reserved"); + expect(probe().className).toBe(CHROME_INSET_TRANSITION_CLASSES); + + advance(PANE_LAYOUT_INSET_TRANSITION_MS); + expect(probe().dataset.inset).toBe("reserved"); + expect(probe().className).toBe(""); + }); + + it("stops animating once the station finishes toggling, even if tabs change meanwhile", () => { + render("tab-a"); + + act(() => store.set(chatPanelMaximizedAtom, true)); + render("tab-b"); + expect(probeClassName()).toBe(CHROME_INSET_TRANSITION_CLASSES); + + advance(PANE_LAYOUT_INSET_TRANSITION_MS); + render("tab-c"); + expect(probeClassName()).toBe(""); + }); +}); diff --git a/src/scaffold/AppLayout/usePaneLayoutInsetTransition.ts b/src/scaffold/AppLayout/usePaneLayoutInsetTransition.ts new file mode 100644 index 0000000000..561b88cf1f --- /dev/null +++ b/src/scaffold/AppLayout/usePaneLayoutInsetTransition.ts @@ -0,0 +1,86 @@ +import { useAtomValue } from "jotai"; +import { useEffect, useState } from "react"; + +import { CHROME_INSET_TRANSITION_CLASSES } from "@src/components/layout/tokens/viewContainerTokens"; +import { effectiveChatPanelMaximizedAtom } from "@src/store/chatPanel/chatPanelLayoutAtoms"; +import { chatWidthAtom } from "@src/store/ui/chatPanel/widthAtoms"; +import { sidebarCollapsedAtom } from "@src/store/ui/sidebarAtom"; + +/** + * How long the inset transition stays on after a pane starts moving: the + * 200ms pane transition plus headroom for the frame that starts it, so the + * class is never removed while the padding is still moving. + */ +export const PANE_LAYOUT_INSET_TRANSITION_MS = 320; + +interface PaneLayoutState { + signature: string; + /** Increments on every layout change; a timer only ends its own change. */ + change: number; + animating: boolean; +} + +/** + * The pane-layout toggles that move a window edge on + * `PANE_WIDTH_TRANSITION_CLASSES`: the sidebar collapsing or expanding, a + * station opening or closing, and the chat pane going away or coming back. + * `AppLayout` sizes the chat slot off the same width sentinel, so a pane is + * in motion exactly when one of these flips. + * + * Deliberately excludes everything that changes a header's reservation + * without moving a pane — opening or closing a tab, switching tabs, sessions + * or stations, a route change. Those show or hide the pinned group in place, + * and there the inset must snap or header controls glide on every tab change. + * Dragging the chat divider is excluded for the same reason: the pane follows + * the pointer with no transition, and only crossing zero changes a + * reservation. + */ +function usePaneLayoutSignature(): string { + const stationOpen = !useAtomValue(effectiveChatPanelMaximizedAtom); + const sidebarCollapsed = useAtomValue(sidebarCollapsedAtom); + const chatPaneOpen = useAtomValue(chatWidthAtom) > 0; + return [stationOpen, sidebarCollapsed, chatPaneOpen] + .map((flag) => (flag ? "1" : "0")) + .join(""); +} + +/** + * Transition classes for a header row that reserves inset under the window's + * pinned chrome, applied only while a pane is actually moving. + * + * A pane-layout toggle slides the panes on the shared pane transition, so the + * reserved inset has to travel with them. Without it the header's content + * jumps by the whole reservation on the first frame and then glides with the + * pane — it lands at a place it never travelled to, then moves. With it the + * inset runs the same 200ms curve as the pane, so the content goes from where + * it is to where it belongs in one continuous move. + * + * The class lands in the same render as the new inset (state is adjusted + * during render) because a CSS transition takes its timing from the + * after-change style. + */ +export function usePaneLayoutInsetTransition(): string { + const signature = usePaneLayoutSignature(); + const [state, setState] = useState(() => ({ + signature, + change: 0, + animating: false, + })); + + if (state.signature !== signature) { + setState({ signature, change: state.change + 1, animating: true }); + } + + const { animating, change } = state; + useEffect(() => { + if (!animating) return undefined; + const timer = setTimeout(() => { + setState((current) => + current.change === change ? { ...current, animating: false } : current + ); + }, PANE_LAYOUT_INSET_TRANSITION_MS); + return () => clearTimeout(timer); + }, [animating, change]); + + return animating ? CHROME_INSET_TRANSITION_CLASSES : ""; +} diff --git a/src/scaffold/AppLayout/useStationToggleInsetTransition.test.ts b/src/scaffold/AppLayout/useStationToggleInsetTransition.test.ts deleted file mode 100644 index 115d4fe7b6..0000000000 --- a/src/scaffold/AppLayout/useStationToggleInsetTransition.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -// @vitest-environment jsdom -import { Provider, createStore } from "jotai"; -import { act, createElement } from "react"; -import { type Root, createRoot } from "react-dom/client"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import { CHROME_INSET_TRANSITION_CLASSES } from "@src/components/layout/tokens/viewContainerTokens"; -import { chatPanelMaximizedAtom } from "@src/store/ui/chatPanel/surfaceAtoms"; - -import { - STATION_TOGGLE_INSET_TRANSITION_MS, - useStationToggleInsetTransition, -} from "./useStationToggleInsetTransition"; - -const reactActEnvironment = globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}; - -/** Stands in for a header row; `activeTabId` changes like a tab switch would. */ -function InsetProbe({ activeTabId }: { activeTabId: string }) { - const className = useStationToggleInsetTransition(); - return createElement("div", { - "data-testid": "inset-probe", - "data-active-tab": activeTabId, - className, - }); -} - -describe("useStationToggleInsetTransition", () => { - let container: HTMLDivElement; - let root: Root; - let store: ReturnType; - - beforeAll(() => { - reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; - }); - - beforeEach(() => { - vi.useFakeTimers(); - // The maximized flag is persisted; start every test with the station open. - localStorage.clear(); - store = createStore(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - vi.useRealTimers(); - }); - - afterAll(() => { - Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); - }); - - function render(activeTabId = "tab-a"): void { - act(() => { - root.render( - createElement( - Provider, - { store }, - createElement(InsetProbe, { activeTabId }) - ) - ); - }); - } - - function probeClassName(): string | null { - return container - .querySelector('[data-testid="inset-probe"]') - ?.getAttribute("class") as string | null; - } - - function advance(ms: number): void { - act(() => { - vi.advanceTimersByTime(ms); - }); - } - - it("keeps insets still at rest and when tabs change inside the station", () => { - render("tab-a"); - expect(probeClassName()).toBe(""); - - render("tab-b"); - expect(probeClassName()).toBe(""); - }); - - it("animates insets while the station closes and opens, then stops", () => { - render(); - - act(() => store.set(chatPanelMaximizedAtom, true)); - expect(probeClassName()).toBe(CHROME_INSET_TRANSITION_CLASSES); - advance(STATION_TOGGLE_INSET_TRANSITION_MS); - expect(probeClassName()).toBe(""); - - act(() => store.set(chatPanelMaximizedAtom, false)); - expect(probeClassName()).toBe(CHROME_INSET_TRANSITION_CLASSES); - advance(STATION_TOGGLE_INSET_TRANSITION_MS); - expect(probeClassName()).toBe(""); - }); - - it("does not let an earlier toggle cut a quick reopen short", () => { - render(); - - act(() => store.set(chatPanelMaximizedAtom, true)); - advance(STATION_TOGGLE_INSET_TRANSITION_MS - 100); - act(() => store.set(chatPanelMaximizedAtom, false)); - - // The first toggle's timer would have fired here. - advance(100); - expect(probeClassName()).toBe(CHROME_INSET_TRANSITION_CLASSES); - - advance(STATION_TOGGLE_INSET_TRANSITION_MS - 100); - expect(probeClassName()).toBe(""); - }); - - it("stops animating once the station finishes toggling, even if tabs change meanwhile", () => { - render("tab-a"); - - act(() => store.set(chatPanelMaximizedAtom, true)); - render("tab-b"); - expect(probeClassName()).toBe(CHROME_INSET_TRANSITION_CLASSES); - - advance(STATION_TOGGLE_INSET_TRANSITION_MS); - render("tab-c"); - expect(probeClassName()).toBe(""); - }); -}); diff --git a/src/scaffold/AppLayout/useStationToggleInsetTransition.ts b/src/scaffold/AppLayout/useStationToggleInsetTransition.ts deleted file mode 100644 index de00626a87..0000000000 --- a/src/scaffold/AppLayout/useStationToggleInsetTransition.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useAtomValue } from "jotai"; -import { useEffect, useState } from "react"; - -import { CHROME_INSET_TRANSITION_CLASSES } from "@src/components/layout/tokens/viewContainerTokens"; -import { effectiveChatPanelMaximizedAtom } from "@src/store/chatPanel/chatPanelLayoutAtoms"; - -/** - * How long the inset transition stays on after a station opens or closes: the - * 200ms pane transition plus headroom for the frame that starts it, so the - * class is never removed while the padding is still moving. - */ -export const STATION_TOGGLE_INSET_TRANSITION_MS = 320; - -interface StationToggleState { - stationOpen: boolean; - /** Increments on every open or close; a timer only ends its own toggle. */ - toggle: number; - animating: boolean; -} - -/** - * Transition classes for a header row that reserves inset under the window's - * pinned chrome, applied only while a station (My Station or Agent Station) - * opens or closes. - * - * Opening or closing a station slides the panes on the shared pane - * transition, so the reserved inset has to travel with them. Every other - * reservation change happens inside a station that stays open — switching - * tabs or sessions shows or hides the pinned group — and there the inset - * must snap, or header controls glide on each tab change. - * - * The class lands in the same render as the new inset (state is adjusted - * during render) because a CSS transition takes its timing from the - * after-change style. - */ -export function useStationToggleInsetTransition(): string { - const stationOpen = !useAtomValue(effectiveChatPanelMaximizedAtom); - const [state, setState] = useState(() => ({ - stationOpen, - toggle: 0, - animating: false, - })); - - if (state.stationOpen !== stationOpen) { - setState({ stationOpen, toggle: state.toggle + 1, animating: true }); - } - - const { animating, toggle } = state; - useEffect(() => { - if (!animating) return undefined; - const timer = setTimeout(() => { - setState((current) => - current.toggle === toggle ? { ...current, animating: false } : current - ); - }, STATION_TOGGLE_INSET_TRANSITION_MS); - return () => clearTimeout(timer); - }, [animating, toggle]); - - return animating ? CHROME_INSET_TRANSITION_CLASSES : ""; -} diff --git a/src/scaffold/WorkbenchChrome/AgentStationTopHeader.tsx b/src/scaffold/WorkbenchChrome/AgentStationTopHeader.tsx index 998b75edb9..19e493ba1f 100644 --- a/src/scaffold/WorkbenchChrome/AgentStationTopHeader.tsx +++ b/src/scaffold/WorkbenchChrome/AgentStationTopHeader.tsx @@ -27,7 +27,7 @@ import { SimulatorAgentChip, StationModeChip, } from "@src/modules/WorkStation/shared"; -import { useStationToggleInsetTransition } from "@src/scaffold/AppLayout/useStationToggleInsetTransition"; +import { usePaneLayoutInsetTransition } from "@src/scaffold/AppLayout/usePaneLayoutInsetTransition"; import { CollapsedSidebarButton } from "@src/scaffold/NavigationSidebar/CollapsedSidebarButton"; import { sessionMapAtom, @@ -54,7 +54,7 @@ const AgentStationTopHeaderComponent = ({ const shouldOffsetLeftChrome = useShouldOffsetWorkStationTopBar(); const collapsedSidebarChromeOffset = useCollapsedSidebarChromeOffset(); const rightEdge = useWorkbenchRightEdgeReservation(); - const insetTransitionClassName = useStationToggleInsetTransition(); + const insetTransitionClassName = usePaneLayoutInsetTransition(); const effectiveDockApp = useAtomValue(simulatorEffectiveDockAppAtom); const [captionEnabled, setCaptionEnabled] = useAtom( simulatorCaptionBarEnabledAtom