From f7a5b1d9fd6fc4d100ebf30fe953a58d2071ebe6 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:52:55 +0800 Subject: [PATCH] feat(station-chrome): animate header insets on sidebar and chat toggles Header rows animated their pinned-chrome inset only while a station opened or closed, so collapsing the app sidebar or closing the chat pane slid the panes while the header content jumped by the whole reservation and then glided. usePaneLayoutInsetTransition replaces useStationToggleInsetTransition and also triggers on the sidebar collapse flag and on the chat pane opening or closing. Tab, session and route changes still snap, and so does dragging the chat divider. --- .../layout/tokens/viewContainerTokens.ts | 8 +- src/engines/ChatPanel/ChatPanelHeader.tsx | 4 +- .../WorkStation/shared/TabBar/index.tsx | 4 +- .../usePaneLayoutInsetTransition.test.ts | 217 ++++++++++++++++++ .../AppLayout/usePaneLayoutInsetTransition.ts | 86 +++++++ .../useStationToggleInsetTransition.test.ts | 139 ----------- .../useStationToggleInsetTransition.ts | 60 ----- .../WorkbenchChrome/AgentStationTopHeader.tsx | 4 +- 8 files changed, 314 insertions(+), 208 deletions(-) create mode 100644 src/scaffold/AppLayout/usePaneLayoutInsetTransition.test.ts create mode 100644 src/scaffold/AppLayout/usePaneLayoutInsetTransition.ts delete mode 100644 src/scaffold/AppLayout/useStationToggleInsetTransition.test.ts delete mode 100644 src/scaffold/AppLayout/useStationToggleInsetTransition.ts 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