diff --git a/apps/web/src/components/AppSidebarLayout.test.tsx b/apps/web/src/components/AppSidebarLayout.test.tsx new file mode 100644 index 000000000000..a3d1e2097952 --- /dev/null +++ b/apps/web/src/components/AppSidebarLayout.test.tsx @@ -0,0 +1,172 @@ +import { act, type ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + pathname: "/", + threadSidebarProps: null as { + projectScopeKey: string | null; + onProjectScopeKeyChange: (projectScopeKey: string | null) => void; + } | null, +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => ({}) })); +vi.mock("@tanstack/react-router", () => ({ + useLocation: ({ select }: { select: (location: { pathname: string }) => unknown }) => + select({ pathname: mocks.pathname }), + useNavigate: () => vi.fn(), +})); +vi.mock("../env", () => ({ isElectron: false })); +vi.mock("../hooks/useLocalStorage", () => ({ + getLocalStorageItem: () => null, + removeLocalStorageItem: vi.fn(), +})); +vi.mock("../hooks/useSettings", () => ({ + useEnvironmentIdentificationMode: () => "none", + useLegacySidebarEnabled: () => false, +})); +vi.mock("../keybindings", () => ({ + resolveShortcutCommand: () => null, + shortcutLabelForCommand: () => null, +})); +vi.mock("../lib/utils", () => ({ + cn: (...values: Array) => values.filter(Boolean).join(" "), + isMacPlatform: () => false, +})); +vi.mock("../panelAnimations", () => ({ + usePanelAnimationSettings: () => ({ active: false, durationMs: 0 }), +})); +vi.mock("../state/entities", () => ({ useProjects: vi.fn() })); +vi.mock("../state/server", () => ({ primaryServerKeybindingsAtom: {} })); +vi.mock("./LegacySidebar", () => ({ default: () => null })); +vi.mock("./Sidebar", () => ({ + default: (props: NonNullable) => { + mocks.threadSidebarProps = props; + return null; + }, +})); +vi.mock("./settings/SettingsSidebarNav", () => ({ SettingsSidebarNav: () => null })); +vi.mock("./sidebar/SidebarChrome", () => ({ SidebarChromeHeader: () => null })); +vi.mock("./SidebarStageBackdrop", () => ({ + resolveSidebarStageFocusRingOffsetClass: () => "", + useSidebarStageBackdropVariant: () => null, +})); +vi.mock("./threadSidebarWidth", () => ({ + resolveInitialThreadSidebarWidth: () => 320, + resolveThreadSidebarMaximumWidth: () => 640, + THREAD_MAIN_CONTENT_MIN_WIDTH: 320, + THREAD_SIDEBAR_MIN_WIDTH: 240, + THREAD_SIDEBAR_WIDTH_STORAGE_KEY: "test-sidebar-width", +})); +vi.mock("./ui/sidebar", () => ({ + Sidebar: ({ children }: { children: ReactNode }) => children, + SidebarProvider: ({ children }: { children: ReactNode }) => children, + SidebarRail: () => null, + SidebarTrigger: () => null, + useSidebar: () => ({ toggleSidebar: vi.fn() }), + useSidebarVisibility: () => true, +})); +vi.mock("./ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipPopup: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ render }: { render: ReactNode }) => render, +})); + +import { AppSidebarLayout } from "./AppSidebarLayout"; + +class TestNode { + parentNode: TestNode | null = null; + childNodes: TestNode[] = []; + nodeValue: string | null = null; + readonly nodeName: string; + readonly tagName: string; + readonly namespaceURI = "http://www.w3.org/1999/xhtml"; + readonly style = {}; + + constructor( + name: string, + readonly ownerDocument: TestNode | null = null, + readonly nodeType = 1, + ) { + this.nodeName = name.toUpperCase(); + this.tagName = this.nodeName; + } + + set textContent(_value: string) { + this.childNodes = []; + } + + appendChild(child: TestNode) { + child.parentNode = this; + this.childNodes.push(child); + return child; + } + + removeChild(child: TestNode) { + this.childNodes.splice(this.childNodes.indexOf(child), 1); + child.parentNode = null; + return child; + } + + createElement(name: string) { + return new TestNode(name, this); + } + + createTextNode(value: string) { + const node = new TestNode("#text", this, 3); + node.nodeValue = value; + return node; + } + + addEventListener() {} + removeEventListener() {} + setAttribute() {} +} + +function installTestDom() { + const document = new TestNode("#document", null, 9); + const window = { + document, + HTMLIFrameElement: TestNode, + innerWidth: 1280, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", window); + vi.stubGlobal("HTMLIFrameElement", window.HTMLIFrameElement); + vi.stubGlobal("navigator", { platform: "test", userAgent: "test" }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + return document; +} + +afterEach(() => { + mocks.pathname = "/"; + mocks.threadSidebarProps = null; + vi.unstubAllGlobals(); +}); + +describe("AppSidebarLayout", () => { + it("keeps the selected project when the thread sidebar unmounts for settings", async () => { + const document = installTestDom(); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(document.createElement("div") as unknown as Element); + const renderLayout = () => root.render({null}); + + try { + await act(renderLayout); + expect(mocks.threadSidebarProps?.projectScopeKey).toBeNull(); + + await act(() => mocks.threadSidebarProps?.onProjectScopeKeyChange("project-2")); + expect(mocks.threadSidebarProps?.projectScopeKey).toBe("project-2"); + + mocks.pathname = "/settings/general"; + await act(renderLayout); + + mocks.pathname = "/"; + await act(renderLayout); + expect(mocks.threadSidebarProps?.projectScopeKey).toBe("project-2"); + } finally { + await act(() => root.unmount()); + } + }); +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3bef170bcf51..129f525b333c 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -142,6 +142,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const legacySidebarEnabled = useLegacySidebarEnabled(); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); + // The thread sidebar unmounts while settings owns this slot, so its active + // project scope lives in the route-spanning layout. + const [projectScopeKey, setProjectScopeKey] = useState(null); // Settings routes show the settings nav in place of whichever thread // sidebar is active. const pathname = useLocation({ select: (location) => location.pathname }); @@ -243,7 +246,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ) : legacySidebarEnabled ? ( ) : ( - + )} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0719f873e6a9..b29ae2a516b8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1851,7 +1851,13 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { ); }); -export default function Sidebar() { +export default function Sidebar({ + projectScopeKey, + onProjectScopeKeyChange, +}: { + projectScopeKey: string | null; + onProjectScopeKeyChange: (projectScopeKey: string | null) => void; +}) { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); @@ -2094,7 +2100,6 @@ export default function Sidebar() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. - const [projectScopeKey, setProjectScopeKey] = useState(null); // {value, label} items let Base UI drive the combobox selection contract // while the popup search filters the same collection. const projectScopeItems = useMemo( @@ -2160,9 +2165,9 @@ export default function Sidebar() { ); useEffect(() => { if (projectScopeKey !== null && scopedProjectGroup === null) { - setProjectScopeKey(null); + onProjectScopeKeyChange(null); } - }, [projectScopeKey, scopedProjectGroup]); + }, [onProjectScopeKeyChange, projectScopeKey, scopedProjectGroup]); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content // subscription. Selecting a number keeps typing in a draft composer from @@ -3711,7 +3716,7 @@ export default function Sidebar() { value={selectedProjectScopeItem} onValueChange={(item) => { if (!item) return; - setProjectScopeKey(item.value === "all" ? null : item.value); + onProjectScopeKeyChange(item.value === "all" ? null : item.value); }} >