Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/components/layout/tokens/viewContainerTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 2 additions & 2 deletions src/engines/ChatPanel/ChatPanelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions src/modules/WorkStation/shared/TabBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -225,7 +225,7 @@ export const TabBar: React.FC<TabBarProps> = 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);
Expand Down
217 changes: 217 additions & 0 deletions src/scaffold/AppLayout/usePaneLayoutInsetTransition.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createStore>;

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("");
});
});
86 changes: 86 additions & 0 deletions src/scaffold/AppLayout/usePaneLayoutInsetTransition.ts
Original file line number Diff line number Diff line change
@@ -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<PaneLayoutState>(() => ({
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 : "";
}
Loading
Loading