Skip to content
Closed
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
172 changes: 172 additions & 0 deletions apps/web/src/components/AppSidebarLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string | false | null | undefined>) => 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<typeof mocks.threadSidebarProps>) => {
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(<AppSidebarLayout>{null}</AppSidebarLayout>);

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());
}
});
});
8 changes: 7 additions & 1 deletion apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
// Settings routes show the settings nav in place of whichever thread
// sidebar is active.
const pathname = useLocation({ select: (location) => location.pathname });
Expand Down Expand Up @@ -243,7 +246,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
) : legacySidebarEnabled ? (
<LegacyThreadSidebar />
) : (
<ThreadSidebar />
<ThreadSidebar
projectScopeKey={projectScopeKey}
onProjectScopeKeyChange={setProjectScopeKey}
/>
)}
<SidebarRail onDoubleClick={resetSidebarWidth} />
</Sidebar>
Expand Down
15 changes: 10 additions & 5 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<string | null>(null);
// {value, label} items let Base UI drive the combobox selection contract
// while the popup search filters the same collection.
const projectScopeItems = useMemo(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}}
>
<ComboboxTrigger
Expand Down
Loading