diff --git a/apps/web/src/components/app/brains-detail-pane.test.tsx b/apps/web/src/components/app/brains-detail-pane.test.tsx
new file mode 100644
index 00000000..8accbeb3
--- /dev/null
+++ b/apps/web/src/components/app/brains-detail-pane.test.tsx
@@ -0,0 +1,468 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import {
+ MemoryRouter,
+ Route,
+ Routes,
+ useLocation,
+ useNavigationType,
+ type Location,
+ type NavigationType,
+} from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { BrainCollectionSummary } from "@/hooks/use-brain";
+import { encodeRepoRoot } from "@/lib/brain-encoding";
+
+import { BrainsDetailPane } from "./brains-detail-pane";
+
+// The pane is mounted on the same three routes the real router declares, so
+// useParams supplies the params exactly as production does. What is under test
+// is everything the pane owns between the URL and its children: decoding the
+// base64url repo root (and recovering when it is malformed), decoding and
+// re-encoding collection names through the path segment, and the destructive
+// "Clear project" flow. The real use-brain queries and mutations run; only the
+// HTTP seam, the toaster, and the already-covered collection view are stubbed.
+vi.mock("@/lib/api", () => ({ api: vi.fn() }));
+// Toaster is stubbed alongside toast so that a component pulled into the tree
+// later fails on its own terms rather than as "Element type is invalid".
+vi.mock("sonner", () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+ Toaster: () => null,
+}));
+
+// BrainCollectionView has its own 590-line suite. Here it stands in as a probe
+// for the three props the pane is responsible for wiring, plus the cleared
+// callback — a real mount would fire its own queries and drown those out.
+vi.mock("@/components/app/brains-collection-view", () => ({
+ BrainCollectionView: ({
+ repoRoot,
+ collection,
+ search,
+ onCollectionCleared,
+ }: {
+ repoRoot: string;
+ collection: string | null;
+ search: string;
+ onCollectionCleared: () => void;
+ }) => (
+
+ {repoRoot}
+ {collection ?? ""}
+ {search}
+
+
+ ),
+}));
+
+const { api } = await import("@/lib/api");
+const { toast } = await import("sonner");
+const apiMock = vi.mocked(api);
+const toastSuccess = vi.mocked(toast.success);
+const toastError = vi.mocked(toast.error);
+
+const REPO = "/Users/brad/dev/apps/dispatch";
+const ENCODED = encodeRepoRoot(REPO);
+
+function summary(
+ collection: string,
+ objectCount = 0,
+ listCount = 0,
+ eventCount = 0
+): BrainCollectionSummary {
+ return { collection, objectCount, listCount, eventCount };
+}
+
+function LocationProbe(): JSX.Element {
+ const location: Location = useLocation();
+ const navigationType: NavigationType = useNavigationType();
+ return (
+ <>
+ {location.pathname}
+ {navigationType}
+ >
+ );
+}
+
+function locationPath(): string {
+ return screen.getByTestId("location").textContent ?? "";
+}
+
+/** "PUSH" leaves the previous entry in history; "REPLACE" discards it. */
+function navigationType(): string {
+ return screen.getByTestId("navigation-type").textContent ?? "";
+}
+
+function mountPane({
+ path = `/automations/brains/${ENCODED}`,
+ collections = [] as BrainCollectionSummary[] | null,
+ onDeleteProject,
+}: {
+ path?: string;
+ /** null keeps the collections query pending, standing for "still loading". */
+ collections?: BrainCollectionSummary[] | null;
+ onDeleteProject?: () => Promise;
+} = {}) {
+ apiMock.mockImplementation(
+ async (requestPath: string, init?: RequestInit) => {
+ if (init?.method === "DELETE") {
+ if (!onDeleteProject)
+ return { objects: 0, lists: 0, events: 0 } as never;
+ return (await onDeleteProject()) as never;
+ }
+ if (requestPath.split("?")[0] === "/api/v1/brain/collections") {
+ if (collections === null) return new Promise(() => {}) as never;
+ return collections as never;
+ }
+ throw new Error(`unexpected request: ${requestPath}`);
+ }
+ );
+
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+
+ render(
+
+
+
+ } />
+ }
+ />
+ }
+ />
+
+
+
+
+ );
+}
+
+function deleteCalls(): string[] {
+ return apiMock.mock.calls
+ .filter(
+ ([, init]) => (init as RequestInit | undefined)?.method === "DELETE"
+ )
+ .map(([path]) => path as string);
+}
+
+function pillName(name: string): RegExp {
+ // A pill's accessible name is the label immediately followed by its count
+ // badge with no separator ("config10"), so the count is matched explicitly
+ // rather than left to a word boundary that never lands between two word
+ // characters. Fully anchored so one label cannot match a longer one.
+ return new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\d*$`);
+}
+
+/** Pills for real collections only appear once the collections query lands. */
+function findPill(name: string): Promise {
+ return screen.findByRole("button", { name: pillName(name) });
+}
+
+function pill(name: string): HTMLElement {
+ return screen.getByRole("button", { name: pillName(name) });
+}
+
+function openProjectDelete() {
+ fireEvent.click(screen.getByRole("button", { name: /Clear project/ }));
+}
+
+function confirmButton(): HTMLElement {
+ return screen.getByRole("button", { name: /Delete permanently|Deleting/ });
+}
+
+beforeEach(() => {
+ apiMock.mockReset();
+ toastSuccess.mockReset();
+ toastError.mockReset();
+});
+
+afterEach(cleanup);
+
+describe("repo root routing", () => {
+ it("shows the explorer placeholder when no project is in the URL", () => {
+ mountPane({ path: "/automations/brains" });
+
+ expect(screen.getByText("Brain Explorer")).toBeTruthy();
+ expect(screen.queryByRole("button", { name: /Clear project/ })).toBeNull();
+ });
+
+ it("decodes the base64url repo root into the project header", () => {
+ mountPane();
+
+ // Both the basename and the full path come out of the decode — a decoder
+ // that dropped the base64url substitutions would render mojibake here.
+ expect(screen.getByRole("heading", { name: "dispatch" })).toBeTruthy();
+ expect(screen.getByTitle(REPO).textContent).toBe(REPO);
+ expect(screen.getByTestId("view-repo-root").textContent).toBe(REPO);
+ });
+
+ it("replaces a malformed repo root in the URL with the overview route", async () => {
+ // "!!!" is not valid base64, so decodeRepoRoot throws. Recovering by
+ // rendering the overview is not enough — the undecodable root has to leave
+ // the address bar too, or a reload or a shared link lands right back on it.
+ mountPane({ path: "/automations/brains/!!!" });
+
+ await waitFor(() => expect(locationPath()).toBe("/automations/brains"));
+ expect(await screen.findByText("Brain Explorer")).toBeTruthy();
+ // Replaced, not pushed: a pushed redirect leaves the undecodable root one
+ // Back press away, and going Back immediately redirects forward again —
+ // the user is trapped on the button.
+ expect(navigationType()).toBe("REPLACE");
+ });
+
+ it("hands the view the real collection name from an escaped path segment", () => {
+ // Collection names may contain slashes, which only survive the path as
+ // %2F — the view must receive the real name, not the escaped one.
+ mountPane({
+ path: `/automations/brains/${ENCODED}/${encodeURIComponent("ops/deploy")}`,
+ collections: [summary("ops/deploy", 1, 0, 0)],
+ });
+
+ expect(screen.getByTestId("view-collection").textContent).toBe(
+ "ops/deploy"
+ );
+ });
+
+ it("leaves a percent sign in the collection name alone", () => {
+ // react-router already decodes path params. Decoding a second time throws
+ // URIError on "50%off" — a collection an agent can create and the sidebar
+ // will happily link to — taking the whole pane down with it.
+ mountPane({
+ path: `/automations/brains/${ENCODED}/${encodeURIComponent("50%off")}`,
+ collections: [summary("50%off", 1, 0, 0)],
+ });
+
+ expect(screen.getByTestId("view-collection").textContent).toBe("50%off");
+ });
+
+ it("does not treat an escape sequence in a collection name as an escape", () => {
+ // "%41" is a legal collection name, not an encoded "A". A second decode
+ // would select a collection that does not exist and render it empty.
+ mountPane({
+ path: `/automations/brains/${ENCODED}/${encodeURIComponent("%41")}`,
+ collections: [summary("%41", 1, 0, 0)],
+ });
+
+ expect(screen.getByTestId("view-collection").textContent).toBe("%41");
+ });
+});
+
+describe("collection pills", () => {
+ it("renders one pill per collection with its summed entry count", async () => {
+ mountPane({
+ collections: [summary("config", 2, 3, 5), summary("notes", 1, 0, 0)],
+ });
+
+ // The badge is the total across all three entry types, not just objects,
+ // and is matched whole so a stray digit cannot stand in for the sum.
+ expect((await findPill("config")).textContent).toBe("config10");
+ expect(pill("notes").textContent).toBe("notes1");
+ });
+
+ it("marks All active while no collection is selected", async () => {
+ mountPane({ collections: [summary("config", 1, 0, 0)] });
+
+ const config = await findPill("config");
+ expect(pill("All").className).toContain("bg-primary/15");
+ expect(config.className).not.toContain("bg-primary/15");
+ });
+
+ it("moves the active marker to the collection named in the URL", async () => {
+ mountPane({
+ path: `/automations/brains/${ENCODED}/config`,
+ collections: [summary("config", 1, 0, 0)],
+ });
+
+ const config = await findPill("config");
+ expect(config.className).toContain("bg-primary/15");
+ expect(pill("All").className).not.toContain("bg-primary/15");
+ });
+
+ it("re-encodes the collection name when navigating to its pill", async () => {
+ mountPane({ collections: [summary("ops/deploy", 1, 0, 0)] });
+
+ fireEvent.click(await findPill("ops/deploy"));
+
+ // A raw slash here would push a route the router does not declare.
+ await waitFor(() =>
+ expect(locationPath()).toBe(`/automations/brains/${ENCODED}/ops%2Fdeploy`)
+ );
+ });
+
+ it("returns to the project root from the All pill", async () => {
+ mountPane({
+ path: `/automations/brains/${ENCODED}/config`,
+ collections: [summary("config", 1, 0, 0)],
+ });
+ await findPill("config");
+
+ fireEvent.click(pill("All"));
+
+ await waitFor(() =>
+ expect(locationPath()).toBe(`/automations/brains/${ENCODED}`)
+ );
+ // Choosing a different collection is ordinary navigation, so it stays on
+ // the history stack — unlike the redirects, which replace.
+ expect(navigationType()).toBe("PUSH");
+ });
+
+ it("replaces history when the view reports its collection cleared", async () => {
+ mountPane({
+ path: `/automations/brains/${ENCODED}/config`,
+ collections: [summary("config", 1, 0, 0)],
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "simulate cleared" }));
+
+ await waitFor(() =>
+ expect(locationPath()).toBe(`/automations/brains/${ENCODED}`)
+ );
+ // The collection no longer exists, so its URL must not stay reachable
+ // through the Back button.
+ expect(navigationType()).toBe("REPLACE");
+ });
+});
+
+describe("search filter", () => {
+ it("passes the typed filter down to the collection view", () => {
+ mountPane();
+
+ fireEvent.change(screen.getByPlaceholderText("Filter..."), {
+ target: { value: "queue" },
+ });
+
+ expect(screen.getByTestId("view-search").textContent).toBe("queue");
+ });
+});
+
+describe("clear project", () => {
+ it("keeps the confirmation closed until the action is pressed", () => {
+ mountPane();
+
+ expect(screen.queryByText("Clear this project?")).toBeNull();
+
+ openProjectDelete();
+
+ expect(screen.getByText("Clear this project?")).toBeTruthy();
+ });
+
+ it("deletes nothing when the confirmation is cancelled", async () => {
+ mountPane();
+
+ openProjectDelete();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ await waitFor(() =>
+ expect(screen.queryByText("Clear this project?")).toBeNull()
+ );
+ expect(deleteCalls()).toHaveLength(0);
+ });
+
+ it("scopes the delete to this project and reports the total removed", async () => {
+ mountPane({
+ onDeleteProject: async () => ({ objects: 4, lists: 2, events: 7 }),
+ });
+
+ openProjectDelete();
+ fireEvent.click(confirmButton());
+
+ await waitFor(() => expect(deleteCalls()).toHaveLength(1));
+ // repoRoot must be query-encoded; an unencoded path would truncate at the
+ // first slash and clear the wrong project.
+ expect(deleteCalls()[0]).toBe(
+ `/api/v1/brain/projects?repoRoot=${encodeURIComponent(REPO)}`
+ );
+ // The toast quotes the sum of all three entry types, not one of them.
+ await waitFor(() =>
+ expect(toastSuccess).toHaveBeenCalledWith(
+ "Deleted 13 entries from this project."
+ )
+ );
+ });
+
+ it("leaves the deleted project for the overview without keeping it in history", async () => {
+ // Only the navigation is asserted here. The dialog does close, but this
+ // test cannot be the thing that proves it: leaving the project unmounts
+ // the dialog either way, so a missing setProjectDeleteOpen(false) is
+ // invisible from the outside.
+ mountPane({
+ onDeleteProject: async () => ({ objects: 1, lists: 0, events: 0 }),
+ });
+
+ openProjectDelete();
+ fireEvent.click(confirmButton());
+
+ await waitFor(() => expect(locationPath()).toBe("/automations/brains"));
+ // Back must not return to a project whose data is gone.
+ expect(navigationType()).toBe("REPLACE");
+ });
+
+ it("keeps the dialog open on the project view when the delete fails", async () => {
+ mountPane({
+ onDeleteProject: async () => {
+ throw new Error("boom");
+ },
+ });
+
+ openProjectDelete();
+ fireEvent.click(confirmButton());
+
+ await waitFor(() =>
+ expect(toastError).toHaveBeenCalledWith(
+ "Could not delete project brain data."
+ )
+ );
+ // Navigating away on a failed delete would hide a project that still has
+ // all of its data.
+ expect(locationPath()).toBe(`/automations/brains/${ENCODED}`);
+ expect(screen.getByText("Clear this project?")).toBeTruthy();
+ expect(toastSuccess).not.toHaveBeenCalled();
+ });
+
+ it("disables both dialog buttons while the delete is in flight", async () => {
+ let release: (value: unknown) => void = () => {};
+ mountPane({
+ onDeleteProject: () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ });
+
+ openProjectDelete();
+ fireEvent.click(confirmButton());
+
+ // A second confirm click during the request would fire a second DELETE.
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Deleting..." })).toBeTruthy()
+ );
+ expect(
+ (screen.getByRole("button", { name: "Deleting..." }) as HTMLButtonElement)
+ .disabled
+ ).toBe(true);
+ expect(
+ (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement)
+ .disabled
+ ).toBe(true);
+
+ release({ objects: 1, lists: 0, events: 0 });
+ await waitFor(() => expect(locationPath()).toBe("/automations/brains"));
+ expect(deleteCalls()).toHaveLength(1);
+ });
+});
diff --git a/apps/web/src/components/app/brains-detail-pane.tsx b/apps/web/src/components/app/brains-detail-pane.tsx
index c58a13f3..d0d69362 100644
--- a/apps/web/src/components/app/brains-detail-pane.tsx
+++ b/apps/web/src/components/app/brains-detail-pane.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { useNavigate, useParams } from "react-router-dom";
+import { Navigate, useNavigate, useParams } from "react-router-dom";
import { Brain, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -26,7 +26,6 @@ import {
import { cn } from "@/lib/utils";
export function BrainsDetailPane(): JSX.Element {
- const navigate = useNavigate();
const { encodedRepoRoot, collection } = useParams<{
encodedRepoRoot?: string;
collection?: string;
@@ -40,15 +39,20 @@ export function BrainsDetailPane(): JSX.Element {
try {
repoRoot = decodeRepoRoot(encodedRepoRoot);
} catch {
- navigate("/automations/brains", { replace: true });
- return ;
+ // Has to be declarative: react-router refuses an imperative navigate()
+ // during render, so calling it here would only warn and leave the
+ // undecodable repo root sitting in the address bar.
+ return ;
}
return (
);
}
diff --git a/apps/web/src/components/app/brains-pane.test.tsx b/apps/web/src/components/app/brains-pane.test.tsx
new file mode 100644
index 00000000..65e0161e
--- /dev/null
+++ b/apps/web/src/components/app/brains-pane.test.tsx
@@ -0,0 +1,285 @@
+// @vitest-environment jsdom
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import {
+ MemoryRouter,
+ Route,
+ Routes,
+ useLocation,
+ type Location,
+} from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { BrainProject } from "@/hooks/use-brain";
+import { encodeRepoRoot } from "@/lib/brain-encoding";
+
+import { BrainsListContent } from "./brains-pane";
+
+// The sidebar owns the encode half of the Brain Explorer's URL contract: it
+// turns a repo root into the base64url segment the detail pane decodes, and
+// decides which row is selected by comparing that encoding against the param
+// already in the URL. It is mounted on the real routes so the comparison runs
+// against genuine params rather than a hand-built string.
+vi.mock("@/lib/api", () => ({ api: vi.fn() }));
+
+const { api } = await import("@/lib/api");
+const apiMock = vi.mocked(api);
+
+const REPO = "/Users/brad/dev/apps/dispatch";
+const OTHER = "/srv/other-repo";
+
+function project(overrides: Partial = {}): BrainProject {
+ return {
+ repoRoot: REPO,
+ objectCount: 0,
+ listCount: 0,
+ eventCount: 0,
+ ...overrides,
+ };
+}
+
+function LocationProbe(): JSX.Element {
+ const location: Location = useLocation();
+ return {location.pathname};
+}
+
+function locationPath(): string {
+ return screen.getByTestId("location").textContent ?? "";
+}
+
+function mountSidebar({
+ path = "/automations/brains",
+ projects = [] as BrainProject[] | null,
+ onItemSelect,
+}: {
+ path?: string;
+ /** null keeps the projects query pending, standing for "still loading". */
+ projects?: BrainProject[] | null;
+ onItemSelect?: () => void;
+} = {}) {
+ apiMock.mockImplementation(async (requestPath: string) => {
+ if (requestPath === "/api/v1/brain/projects") {
+ if (projects === null) return new Promise(() => {}) as never;
+ return projects as never;
+ }
+ throw new Error(`unexpected request: ${requestPath}`);
+ });
+
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+
+ const element = ;
+ render(
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function row(name: string): Promise {
+ return screen.findByRole("button", { name: new RegExp(name) });
+}
+
+beforeEach(() => {
+ apiMock.mockReset();
+});
+
+afterEach(cleanup);
+
+describe("list states", () => {
+ it("shows placeholders while the projects query is pending", async () => {
+ mountSidebar({ projects: null });
+
+ // Distinguishable from the empty state: neither the empty copy nor any row
+ // may appear before the answer is known.
+ await waitFor(() =>
+ expect(
+ document.querySelectorAll(".animate-pulse").length
+ ).toBeGreaterThan(0)
+ );
+ expect(screen.queryByText("No brain data yet.")).toBeNull();
+ });
+
+ it("explains the empty state once the query resolves with nothing", async () => {
+ mountSidebar({ projects: [] });
+
+ expect(await screen.findByText("No brain data yet.")).toBeTruthy();
+ expect(document.querySelectorAll(".animate-pulse")).toHaveLength(0);
+ });
+
+ it("shows the basename, the shortened path, and the full path on hover", async () => {
+ mountSidebar({ projects: [project()] });
+
+ await row("dispatch");
+ // Exact text, not a substring of the row: the full repo root also ends in
+ // "dispatch", so a row that skipped repoBasename would still contain it.
+ expect(screen.getByText("dispatch", { selector: "div" })).toBeTruthy();
+ // shortPath keeps only the last three segments; the untruncated root stays
+ // reachable through the title attribute.
+ expect(screen.getByText(".../dev/apps/dispatch")).toBeTruthy();
+ expect(screen.getByTitle(REPO)).toBeTruthy();
+ });
+});
+
+describe("entry counts", () => {
+ it("renders a count per entry type that has entries", async () => {
+ mountSidebar({
+ projects: [project({ objectCount: 4, listCount: 9, eventCount: 2 })],
+ });
+
+ const item = await row("dispatch");
+ expect(item.textContent).toContain("4");
+ expect(item.textContent).toContain("9");
+ expect(item.textContent).toContain("2");
+ });
+
+ it("omits an entry type with a zero count instead of showing a 0", async () => {
+ // Each type is gated independently, so one fixture carrying a single
+ // non-zero count leaves the other two guards free to fall away unnoticed —
+ // their mutant renders the same row. Every type therefore gets a row where
+ // it is the only non-zero count, and a row where it is zero.
+ mountSidebar({
+ projects: [
+ project({ repoRoot: "/a/objects-only", objectCount: 7 }),
+ project({ repoRoot: "/a/lists-only", listCount: 7 }),
+ project({ repoRoot: "/a/events-only", eventCount: 7 }),
+ ],
+ });
+
+ for (const name of ["objects-only", "lists-only", "events-only"]) {
+ const item = await row(name);
+ expect(item.textContent).toContain("7");
+ expect(item.textContent).not.toContain("0");
+ }
+ });
+});
+
+describe("navigation", () => {
+ it("routes a project row to its base64url-encoded repo root", async () => {
+ mountSidebar({ projects: [project()] });
+
+ fireEvent.click(await row("dispatch"));
+
+ // This is the exact segment the detail pane decodes back into a repo root.
+ await waitFor(() =>
+ expect(locationPath()).toBe(`/automations/brains/${encodeRepoRoot(REPO)}`)
+ );
+ });
+
+ it("returns to the overview from the Overview row", async () => {
+ mountSidebar({
+ path: `/automations/brains/${encodeRepoRoot(REPO)}`,
+ projects: [project()],
+ });
+
+ fireEvent.click(await screen.findByRole("button", { name: /Overview/ }));
+
+ await waitFor(() => expect(locationPath()).toBe("/automations/brains"));
+ });
+
+ it("closes the mobile sidebar after either kind of selection", async () => {
+ const onItemSelect = vi.fn();
+ mountSidebar({ projects: [project()], onItemSelect });
+
+ fireEvent.click(await row("dispatch"));
+ expect(onItemSelect).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByRole("button", { name: /Overview/ }));
+ expect(onItemSelect).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe("keyboard activation", () => {
+ it("opens a project with Enter", async () => {
+ const onItemSelect = vi.fn();
+ mountSidebar({ projects: [project()], onItemSelect });
+
+ fireEvent.keyDown(await row("dispatch"), { key: "Enter" });
+
+ await waitFor(() =>
+ expect(locationPath()).toBe(`/automations/brains/${encodeRepoRoot(REPO)}`)
+ );
+ expect(onItemSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it("opens a project with Space", async () => {
+ // Kept separate from the Enter case rather than firing both at one
+ // captured node: the first key navigates, and re-using a node captured
+ // before that route change would only work by accident of reconciliation.
+ const onItemSelect = vi.fn();
+ mountSidebar({ projects: [project()], onItemSelect });
+
+ fireEvent.keyDown(await row("dispatch"), { key: " " });
+
+ await waitFor(() =>
+ expect(locationPath()).toBe(`/automations/brains/${encodeRepoRoot(REPO)}`)
+ );
+ expect(onItemSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it("swallows the Space keypress so the sidebar does not scroll", async () => {
+ mountSidebar({ projects: [project()] });
+
+ const item = await row("dispatch");
+ const event = new KeyboardEvent("keydown", {
+ key: " ",
+ bubbles: true,
+ cancelable: true,
+ });
+ item.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("ignores keys that are not Enter or Space", async () => {
+ const onItemSelect = vi.fn();
+ mountSidebar({ projects: [project()], onItemSelect });
+
+ fireEvent.keyDown(await row("dispatch"), { key: "a" });
+
+ expect(onItemSelect).not.toHaveBeenCalled();
+ expect(locationPath()).toBe("/automations/brains");
+ });
+});
+
+describe("selection highlight", () => {
+ it("marks only the project whose encoding matches the URL", async () => {
+ mountSidebar({
+ path: `/automations/brains/${encodeRepoRoot(REPO)}`,
+ projects: [project(), project({ repoRoot: OTHER })],
+ });
+
+ expect((await row("dispatch")).className).toContain("border-r-primary");
+ expect((await row("other-repo")).className).not.toContain(
+ "border-r-primary"
+ );
+ // Overview has to give up its highlight too, or two rows read as current.
+ expect(
+ screen.getByRole("button", { name: /Overview/ }).className
+ ).not.toContain("border-r-primary");
+ });
+
+ it("marks Overview instead when no project is in the URL", async () => {
+ mountSidebar({ projects: [project()] });
+
+ const overview = await screen.findByRole("button", { name: /Overview/ });
+ expect(overview.className).toContain("border-r-primary");
+ expect((await row("dispatch")).className).not.toContain("border-r-primary");
+ });
+});