diff --git a/plugins/stewards/DESIGN.md b/plugins/stewards/DESIGN.md index 0eeba70..173d719 100644 --- a/plugins/stewards/DESIGN.md +++ b/plugins/stewards/DESIGN.md @@ -901,8 +901,10 @@ deployment: # where a merge is confirmed live (BB-53); omi blocked / on hold / stuck lines and the meta line), **Threads** (the steward, the workers and the pull request) and the lessons the effort wrote. An ended effort shows the key, title, how it ended and the archived thread; an unknown - key says so. The page's own sidebar is the same list - (`experimental_sidebar`), told the project and effort the path names. + key says so. The page has no sidebar of its own: it publishes the project and + effort its path names through the module-level store in + [board-route.ts](board-route.ts) (BB-94), and the sidebar thread list follows + it, so the list is not remounted when the page opens. Settings → Plugins → Stewards keeps each project's configuration and health. ### Changes to linear and github @@ -1596,10 +1598,15 @@ thread row is highlighted while its thread page is the route (`bg-state-active`, with `aria-current="page"` on the anchor). When navigation lands on a row inside a collapsed parent, the parent expands so the row is revealed: the effort's row for a worker or reviewer, the **Threads** section for a one-off -thread, and **Monitors** or **Recently ended** for their rows. The board panel -registers the same list as its own `experimental_sidebar`, told the project and -effort the path names, so the list is there whichever thread list the user -pinned; a project pick in it moves the page. +thread, and **Monitors** or **Recently ended** for their rows. The Stewards +page has no sidebar of its own (BB-94): the host mounts one list, in the +`experimental_threadList` slot, and it stays mounted across the page the way it +does across thread pages. `BoardPage` publishes the project and effort its path +names through a module-level store ([board-route.ts](board-route.ts)), +subscribed with `useSyncExternalStore`; the list highlights and reveals that +effort and follows that project, and clears both when the page unmounts. A +project pick while the page is mounted moves it. A user who pinned bb's own +list sees bb's list on the Stewards page rather than the stewards one. **Grouping is the steward relationship, then the thread tree.** A worker belongs to its binding through the plugin's `workers` table and a reviewer diff --git a/plugins/stewards/README.md b/plugins/stewards/README.md index 58fbb42..6210899 100644 --- a/plugins/stewards/README.md +++ b/plugins/stewards/README.md @@ -351,9 +351,10 @@ bb. [DESIGN.md](DESIGN.md) is the design; this is how it is built and run. and the meta line with the stage and deployment); **Threads** (the steward, the workers and the pull request); and the lessons the effort wrote. An ended effort shows the key, title, how it ended and the archived thread; an unknown - key says so. The page registers its own sidebar (`experimental_sidebar`), the - same list told the project and effort the path names, so it is there whichever - thread list the user pinned. + key says so. The page has no sidebar of its own (BB-94): it publishes the + project and effort its path names through [board-route.ts](board-route.ts), + and the sidebar thread list follows it, so the list is not remounted when the + page opens. - **Questions and asks to you** are escalations with `route: me`, kept in the store from the moment the project steward asks (`steward_update { ask }`, or forwarding an escalation) until it records diff --git a/plugins/stewards/app.tsx b/plugins/stewards/app.tsx index 993f93c..3fee798 100644 --- a/plugins/stewards/app.tsx +++ b/plugins/stewards/app.tsx @@ -4,8 +4,9 @@ // and the lessons block) and `board//` is one effort's detail // (its two-line row one size up, its needs-you items with the page's full // controls, its status, threads and lessons). The sidebar (the opt-in -// `experimental_threadList`, or the panel's own `experimental_sidebar`) is the -// list; the one write is closing a question put to you or pressing a needs +// `experimental_threadList`) is the list; the page publishes the project and +// effort its path names through `board-route.ts`, so the list follows it. The +// one write is closing a question put to you or pressing a needs // item's button. Settings → Plugins → Stewards: which projects are stewarded // and whether their configuration is healthy, then every open steward and // where it stands. Steering stays with the project steward and the CLI. @@ -26,13 +27,13 @@ import { useSdk, useSidebarThreadDraft, useSidebarThreadRowStatus, - type PluginNavPanelProps, type PluginSidebarThread, type PluginThreadActionTarget, type PluginThreadListProps, } from "@get-bb/plugin-sdk/app"; import type { rpcContract } from "./server"; import { PROJECT_CHANGED } from "./channels.js"; +import { setBoardRoute, useBoardRoute } from "./board-route.js"; import { buildSidebar, compareBlocked, compareEfforts, compareInflight, countThreads, deploymentPhrase, effortPhrase, effortSection, effortState, effortTitle, hasUnread, isBusy, isUnread, type EffortStatus, type SidebarChild, type SidebarChildRole, type SidebarEffort, type SidebarEffortState, type SidebarSection, type SidebarTab } from "./sidebar.js"; import { AUTOMATIC_THREAD_LIST_PROVIDER, BUILTIN_THREAD_LIST_ID, THREAD_LIST_PROVIDER_KEY, stewardsThreadListId } from "./sidebar-provider.js"; @@ -478,6 +479,14 @@ function BoardPage({ subPath }: { subPath: string }) { const rpc = useRpc(); const navigate = useBbNavigate(); const { projectId: wanted, key } = parseBoardPath(subPath); + // Publish the project and effort the path names for the sidebar thread list + // (BB-94), which the host mounts outside this panel. The effect keyed on the + // path republishes as it changes; the empty-deps effect clears the store when + // the panel unmounts, so a thread route falls back to its own props. + useEffect(() => { + setBoardRoute({ projectId: wanted, key }); + }, [wanted, key]); + useEffect(() => () => setBoardRoute({ projectId: null, key: null }), []); const { data, error, refetch } = usePolled(useCallback(() => rpc.call("stewardsBoard", { projectId: wanted }), [rpc, wanted])); const boardProjectId = data?.board?.projectId ?? null; // A path with no project takes the server's choice, replacing rather than @@ -1179,13 +1188,13 @@ function QuestionControls({ question: q, onClosed }: { question: BoardQuestion; // ---- The sidebar thread list ------------------------------------------------------------------- // // The opt-in replacement for bb's sidebar list, registered through the -// exclusive `experimental_threadList` slot, and the same list as the board -// panel's own sidebar (`experimental_sidebar`, BB-85). One project dropdown, +// exclusive `experimental_threadList` slot. One project dropdown, // two-line effort rows with the four glyphs in the Blocked / In flight / // Threads / Monitors / Recently ended sections, and a demoted project-steward // row. An effort row and a Recently ended row open the detail view; worker, -// reviewer, one-off and project-steward rows open their thread; the row the -// board shows is highlighted and navigation expands a collapsed parent. Every +// reviewer, one-off and project-steward rows open their thread; the effort the +// Stewards page shows is highlighted and navigation expands a collapsed parent +// (the page publishes it through `board-route.ts`, BB-94). Every // effort and thread row carries bb's own thread actions context menu, so // nothing bb's rows can do is lost. @@ -1229,17 +1238,14 @@ function phraseClass(state: SidebarEffortState): string { } /** - * A CSS-safe `view-transition-name` for one row, from the surface, row kind and - * key (BB-91). The surface keeps the two sidebar instances (the pinned thread - * list and the board panel's own sidebar) apart, since both can be mounted at - * once and duplicate names skip the whole transition; the kind keeps an effort - * row and an ended row with the same binding key apart; the hash keeps two keys - * that sanitise alike apart. + * A CSS-safe `view-transition-name` for one row, from the row kind and key + * (BB-91): the kind keeps an effort row and an ended row with the same binding + * key apart, and the hash keeps two keys that sanitise alike apart. */ -function viewTransitionName(surface: string, kind: "effort" | "ended" | "thread", key: string): string { +function viewTransitionName(kind: "effort" | "ended" | "thread", key: string): string { let hash = 0; for (let i = 0; i < key.length; i += 1) hash = (Math.imul(hash, 31) + key.charCodeAt(i)) | 0; - return `stewards-${kind}-${surface}-${key.replace(/[^A-Za-z0-9_-]/gu, "-")}-${(hash >>> 0).toString(36)}`; + return `stewards-${kind}-${key.replace(/[^A-Za-z0-9_-]/gu, "-")}-${(hash >>> 0).toString(36)}`; } /** @@ -1274,7 +1280,6 @@ function SidebarThreadRow({ glyph, activeThreadId, openThreads, - viewTransitionPrefix, entered = false, }: { thread: PluginSidebarThread; @@ -1293,8 +1298,6 @@ function SidebarThreadRow({ activeThreadId: string | null; /** The rows that must be open so the active thread is revealed (BB-85). */ openThreads: ReadonlySet; - /** The surface this row is rendered on, so its view-transition name is unique across mounted sidebars (BB-91). */ - viewTransitionPrefix: string; /** Whether the first load has painted, so a row that mounts later animates in (BB-91). */ entered?: boolean; }) { @@ -1317,7 +1320,7 @@ function SidebarThreadRow({ const useStewardGlyph = glyph !== undefined && indicator !== "draft" && indicator !== "working-draft"; const count = countThreads(children); return ( -
  • +
  • {count > 0 ? ( @@ -1360,7 +1363,7 @@ function SidebarThreadRow({ {children.map((c) => { const child = byId.get(c.threadId); return child === undefined ? null : ( - + ); })} @@ -1453,7 +1456,6 @@ function SidebarEffortRow({ trailing, rowKind = "effort", viewTransitionKey, - viewTransitionPrefix, entered = false, }: { effort: SidebarEffort; @@ -1474,8 +1476,6 @@ function SidebarEffortRow({ rowKind?: "effort" | "ended"; /** The row's stable key for the view-transition name, when it differs from the effort key (an ended row's `endedAt`). */ viewTransitionKey?: string; - /** The surface this row is rendered on, so its view-transition name is unique across mounted sidebars (BB-91). */ - viewTransitionPrefix: string; /** Whether the first load has painted, so a row that mounts later animates in (BB-91). */ entered?: boolean; }) { @@ -1545,7 +1545,7 @@ function SidebarEffortRow({
    ); return ( -
  • +
  • {root === undefined ? body : {body}} {mine.length > 0 ? (
    @@ -1560,7 +1560,7 @@ function SidebarEffortRow({ {effort.children.map((c) => { const child = byId.get(c.threadId); return child === undefined ? null : ( - + ); })} @@ -1717,26 +1717,21 @@ function revealSections(tabs: readonly SidebarTab[], activeThreadId: string | nu } /** - * The list itself, shared by the sidebar's thread list and the board panel's - * own sidebar (BB-85). The panel passes the project and effort the path names - * and navigates on a project pick; the thread list passes the route's thread. + * The sidebar thread list (BB-44), mounted by the host in the + * `experimental_threadList` slot. It follows the thread route through its + * `activeThreadId` and `activeProjectId` props, and the Stewards page through + * the board-route store (BB-94): while that panel is mounted the store names + * the project and effort its path shows, and a project pick moves the page. */ function StewardsSidebarCore({ - surface, activeProjectId, - activeKey, activeThreadId, onNavigate, - onPickProject, onNewThread, }: { - /** Which mounted sidebar this is; folded into each row's view-transition name (BB-91). */ - surface: "sidebar" | "panel"; activeProjectId: string | null; - activeKey: string | null; activeThreadId: string | null; onNavigate: () => void; - onPickProject?: (projectId: string) => void; onNewThread: (projectId: string) => void; }) { const rpc = useRpc(); @@ -1760,19 +1755,26 @@ function StewardsSidebarCore({ ), ); const byId = useMemo(() => new Map(threads.map((t) => [t.id, t] as const)), [threads]); + // The Stewards page's route, when that panel is mounted (BB-94). It is null + // on a thread route, where the props above are the source. + const boardRoute = useBoardRoute(); + const navigate = useBbNavigate(); const [picked, setPicked] = useState(null); - // The active tab follows the route; a pick only lasts until the route moves. - useEffect(() => setPicked(null), [activeProjectId]); - const wanted = picked ?? activeProjectId; + // The active tab follows the panel route, then the thread route; a pick only + // lasts until one of them moves. + useEffect(() => setPicked(null), [activeProjectId, boardRoute.projectId]); + const wanted = picked ?? boardRoute.projectId ?? activeProjectId; const active = tabs.find((t) => t.projectId === wanted) ?? tabs[0] ?? null; const activeNeeds = active === null ? undefined : needsByProject.get(active.projectId); const now = Date.now(); + const activeKey = boardRoute.key; const openThreads = useMemo(() => revealThreads(tabs, activeThreadId), [tabs, activeThreadId]); const openSections = useMemo(() => revealSections(tabs, activeThreadId, activeKey), [tabs, activeThreadId, activeKey]); - // The picker changes local state and, in the board panel, moves the page. + // The picker changes local state and, while the Stewards panel is mounted, + // moves the page so the two cannot disagree. const pick = (projectId: string) => { setPicked(projectId); - onPickProject?.(projectId); + if (boardRoute.projectId !== null) navigate.toPluginPanel(BOARD_PATH, { subPath: boardSubPath(projectId) }); }; const needItems = activeNeeds?.items ?? []; const rows = (list: readonly SidebarEffort[]) => @@ -1788,7 +1790,6 @@ function StewardsSidebarCore({ openThreads={openThreads} onNavigate={onNavigate} onChanged={refetch} - viewTransitionPrefix={surface} entered={loaded} /> )); @@ -1827,7 +1828,7 @@ function StewardsSidebarCore({ {active.threads.map((e) => { const t = byId.get(e.rootThreadId); return t === undefined ? null : ( - + ); })} @@ -1856,7 +1857,6 @@ function StewardsSidebarCore({ trailing={{ago(e.endedAt, now)}} rowKind="ended" viewTransitionKey={`${e.key}-${e.endedAt}`} - viewTransitionPrefix={surface} entered={loaded} /> ))} @@ -1875,7 +1875,6 @@ function StewardsSidebarCore({ glyph="quiet" activeThreadId={activeThreadId} openThreads={openThreads} - viewTransitionPrefix={surface} entered={loaded} meta={project steward} /> @@ -1893,9 +1892,7 @@ function StewardsSidebar({ activeProjectId, activeThreadId, onNavigate }: Plugin const navigate = useBbNavigate(); return ( { @@ -1906,23 +1903,6 @@ function StewardsSidebar({ activeProjectId, activeThreadId, onNavigate }: Plugin ); } -/** The board panel's own sidebar (BB-85): the same list, told the path's project and effort. */ -function StewardsPanelSidebar({ subPath }: PluginNavPanelProps) { - const navigate = useBbNavigate(); - const { projectId, key } = parseBoardPath(subPath); - return ( - {}} - onPickProject={(id) => navigate.toPluginPanel(BOARD_PATH, { subPath: boardSubPath(id) })} - onNewThread={(id) => navigate.toCompose({ projectId: id })} - /> - ); -} - // ---- The sidebar-footer toggle ----------------------------------------------------------------- // // BB-45: an icon in the sidebar footer that opens a small panel above the @@ -2031,9 +2011,6 @@ export default definePluginApp((app) => { icon: "ShieldCheck", path: BOARD_PATH, component: BoardPage, - // The panel's own sidebar (BB-85), so the list is there whichever thread - // list the user pinned. - experimental_sidebar: StewardsPanelSidebar, }); // The exclusive sidebar slot: while this plugin is enabled and no provider is // pinned, this list replaces bb's own (see DESIGN.md, "The sidebar thread list"). diff --git a/plugins/stewards/board-route.ts b/plugins/stewards/board-route.ts new file mode 100644 index 0000000..f78b387 --- /dev/null +++ b/plugins/stewards/board-route.ts @@ -0,0 +1,41 @@ +// The board panel's route, published by `BoardPage` and read by the sidebar +// thread list (BB-94). The list is mounted by the host, not by the panel, so it +// has no `subPath`: `activeThreadId` and `activeProjectId` are null (or stale) +// while the Stewards page is the route, and this store is how the list learns +// which project and effort that page shows. `{ projectId: null, key: null }` +// whenever the panel is not mounted, which is also the "is the panel active" +// test the project picker uses. +import { useSyncExternalStore } from "react"; + +export interface BoardRoute { + projectId: string | null; + key: string | null; +} + +const EMPTY: BoardRoute = { projectId: null, key: null }; + +let route: BoardRoute = EMPTY; +const listeners = new Set<() => void>(); + +/** Publish the panel's route. A no-op when nothing changed, so `getSnapshot` stays referentially stable. */ +export function setBoardRoute(next: BoardRoute): void { + if (route.projectId === next.projectId && route.key === next.key) return; + route = { projectId: next.projectId, key: next.key }; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): BoardRoute { + return route; +} + +/** The board panel's route, or `{ null, null }` while the panel is not mounted. */ +export function useBoardRoute(): BoardRoute { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/plugins/stewards/sidebar-app.test.tsx b/plugins/stewards/sidebar-app.test.tsx index 46801d6..88f3f8c 100644 --- a/plugins/stewards/sidebar-app.test.tsx +++ b/plugins/stewards/sidebar-app.test.tsx @@ -7,6 +7,7 @@ import { fireEvent, waitFor } from "@testing-library/react"; import { loadPluginApp, renderSlot, type CapturedPluginApp, type RenderedSlot } from "@get-bb/plugin-sdk/testing/app"; import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app"; import { PROJECT_CHANGED } from "./channels.js"; +import { setBoardRoute } from "./board-route.js"; const thread = (over: Partial & { id: string }): PluginSidebarThread => ({ projectId: "p1", @@ -154,6 +155,7 @@ const renderSidebar = async (options: Parameters[2] = {}, pro afterEach(() => { app = null; + setBoardRoute({ projectId: null, key: null }); delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; }); @@ -973,60 +975,122 @@ describe("the sidebar's highlighting and auto-expand (BB-85)", () => { expect(old.closest("a")!.getAttribute("href")).toContain("/board/p1/ENG-0"); ended.lifecycle.unmount(); }); +}); - it("highlights a Recently ended row while its detail page is showing", async () => { - const captured = await loadApp(); - const panel = captured.navPanels[0]!; - const slot = renderSlot({ component: panel.experimental_sidebar! }, { subPath: "p1/ENG-0" }, { - sidebarThreads: { projects, threads: [] }, - rpc: { stewardsSidebar: () => sidebarData({ ended: [{ key: "ENG-0", kind: "ticket", threadId: "thr_old", title: "Old work", phase: "Done", endReason: "terminal", endedAt: 1_000 }] }) } as never, - }); - const old = await waitFor(() => slot.getByText("Old work")); - expect(old.closest("a")!.getAttribute("aria-current")).toBe("page"); - slot.lifecycle.unmount(); +// BB-94: the board panel publishes its route to the sidebar thread list, which +// the host mounts outside the panel, so the list highlights and reveals the +// effort the Stewards page shows even with no active thread. +describe("the board route store (BB-94)", () => { + const boardData = () => ({ + projects: [{ projectId: "p1", name: "Acme" }], + board: { + projectId: "p1", + name: "Acme", + paused: false, + projectStewardThreadId: null, + stages: [], + digest: null, + questions: [], + withUser: [], + needs: [], + lessons: [], + lessonCounts: { candidate: 0, trial: 0, promoted: 0, retired: 0 }, + stewards: [], + stuck: [], + ended: [], + }, }); -}); -// BB-85: the board panel's own sidebar, so the list is there whichever thread -// list the user pinned. -describe("the board panel's own sidebar (BB-85)", () => { - it("registers the panel sidebar and renders the same list told the path's project and effort", async () => { + it("highlights and reveals the effort the panel's path names, and clears when the panel unmounts", async () => { const captured = await loadApp(); + const list = captured.threadLists[0]!; const panel = captured.navPanels[0]!; - expect(panel.experimental_sidebar).toBeTruthy(); - const slot = renderSlot({ component: panel.experimental_sidebar! }, { subPath: "p1/ENG-1" }, { - sidebarThreads: { projects, threads: [thread({ id: "thr_steward", displayTitle: "ENG-1: Fix login" })] }, - rpc: { stewardsSidebar: () => sidebarData({ bindings: [binding()] }) } as never, - }); - const row = await waitFor(() => slot.getByText("Fix login")); + const options = { + sidebarThreads: { projects, threads: [thread({ id: "thr_ops", displayTitle: "Ops watch: acme/web" })] }, + rpc: { + stewardsSidebar: () => sidebarData({ bindings: [binding({ key: "acme/web", kind: "ops", stage: null, threadId: "thr_ops" })] }), + stewardsBoard: () => boardData(), + } as never, + }; + // The list is mounted with no active thread, so only the store can name + // the page's project and effort. + const listSlot = renderSlot(list, { activeThreadId: null, activeProjectId: "p1", isCompactViewport: false, onNavigate: () => {}, searchQuery: "" }, options); + const monitors = (await waitFor(() => listSlot.getByRole("button", { name: /Monitors/u }))).closest("section")!; + expect(monitors.querySelector("[data-sidebar-collapsible]")!.className).toContain("grid-rows-[0fr]"); + // Mounting the page publishes its route. + const panelSlot = renderSlot(panel, { subPath: "p1/acme/web" }, options); + const row = await waitFor(() => listSlot.getByText("acme/web")); expect(row.closest("a")!.getAttribute("aria-current")).toBe("page"); - expect(row.closest("a")!.getAttribute("href")).toBe("/plugins/test-plugin/board/p1/ENG-1"); - slot.lifecycle.unmount(); + // Monitors is revealed for the row inside it. + await waitFor(() => expect(monitors.querySelector("[data-sidebar-collapsible]")!.className).toContain("grid-rows-[1fr]")); + // Unmounting the page clears the store: the highlight goes. + panelSlot.lifecycle.unmount(); + await waitFor(() => expect(row.closest("a")!.getAttribute("aria-current")).toBeNull()); + listSlot.lifecycle.unmount(); }); - it("moves the page on a project pick", async () => { + it("follows the panel's project in the picker and returns to the thread route's on unmount", async () => { const captured = await loadApp(); + const list = captured.threadLists[0]!; const panel = captured.navPanels[0]!; - const slot = renderSlot({ component: panel.experimental_sidebar! }, { subPath: "p1" }, { + const options = { sidebarThreads: { projects, threads: [] }, - rpc: { stewardsSidebar: () => sidebarData() } as never, - }); - const select = await waitFor(() => slot.getByLabelText("Project") as HTMLSelectElement); - fireEvent.change(select, { target: { value: "personal" } }); - await waitFor(() => expect(slot.inspection.navigateCalls).toContainEqual({ method: "toPluginPanel", path: "board", options: { subPath: "personal" } })); - slot.lifecycle.unmount(); + rpc: { + stewardsSidebar: () => sidebarData(), + stewardsBoard: () => boardData(), + } as never, + }; + const listSlot = renderSlot(list, { activeThreadId: null, activeProjectId: "personal", isCompactViewport: false, onNavigate: () => {}, searchQuery: "" }, options); + const select = await waitFor(() => listSlot.getByLabelText("Project") as HTMLSelectElement); + expect(select.value).toBe("personal"); + const panelSlot = renderSlot(panel, { subPath: "p1" }, options); + await waitFor(() => expect(select.value).toBe("p1")); + panelSlot.lifecycle.unmount(); + await waitFor(() => expect(select.value).toBe("personal")); + listSlot.lifecycle.unmount(); }); - it("expands Monitors when the path's effort is a monitor", async () => { + it("reveals a Recently ended row the panel's path names, with no active thread", async () => { const captured = await loadApp(); + const list = captured.threadLists[0]!; const panel = captured.navPanels[0]!; - const slot = renderSlot({ component: panel.experimental_sidebar! }, { subPath: "p1/acme/web" }, { - sidebarThreads: { projects, threads: [thread({ id: "thr_ops", displayTitle: "Ops watch: acme/web" })] }, - rpc: { stewardsSidebar: () => sidebarData({ bindings: [binding({ key: "acme/web", kind: "ops", stage: null, threadId: "thr_ops" })] }) } as never, - }); - const row = await waitFor(() => slot.getByText("acme/web")); + const options = { + sidebarThreads: { projects, threads: [] }, + rpc: { + stewardsSidebar: () => sidebarData({ ended: [{ key: "ENG-0", kind: "ticket", threadId: "thr_old", title: "Old work", phase: "Done", endReason: "terminal", endedAt: 1_000 }] }), + stewardsBoard: () => boardData(), + } as never, + }; + const listSlot = renderSlot(list, { activeThreadId: null, activeProjectId: "p1", isCompactViewport: false, onNavigate: () => {}, searchQuery: "" }, options); + const ended = await waitFor(() => listSlot.getByRole("button", { name: /Recently ended/u }).closest("section")!); + expect(ended.querySelector("[data-sidebar-collapsible]")!.className).toContain("grid-rows-[0fr]"); + const panelSlot = renderSlot(panel, { subPath: "p1/ENG-0" }, options); + const row = await waitFor(() => listSlot.getByText("Old work")); expect(row.closest("a")!.getAttribute("aria-current")).toBe("page"); - slot.lifecycle.unmount(); + await waitFor(() => expect(ended.querySelector("[data-sidebar-collapsible]")!.className).toContain("grid-rows-[1fr]")); + panelSlot.lifecycle.unmount(); + listSlot.lifecycle.unmount(); + }); + + it("moves the page when a project is picked while the panel is mounted", async () => { + const captured = await loadApp(); + const list = captured.threadLists[0]!; + const panel = captured.navPanels[0]!; + const options = { + sidebarThreads: { projects, threads: [] }, + rpc: { + stewardsSidebar: () => sidebarData(), + stewardsBoard: () => boardData(), + } as never, + }; + const listSlot = renderSlot(list, { activeThreadId: null, activeProjectId: null, isCompactViewport: false, onNavigate: () => {}, searchQuery: "" }, options); + const panelSlot = renderSlot(panel, { subPath: "p1" }, options); + const select = await waitFor(() => listSlot.getByLabelText("Project") as HTMLSelectElement); + await waitFor(() => expect(select.value).toBe("p1")); + fireEvent.change(select, { target: { value: "personal" } }); + await waitFor(() => expect(listSlot.inspection.navigateCalls).toContainEqual({ method: "toPluginPanel", path: "board", options: { subPath: "personal" } })); + panelSlot.lifecycle.unmount(); + listSlot.lifecycle.unmount(); }); }); @@ -1058,28 +1122,6 @@ describe("the sidebar's row transitions (BB-91)", () => { slot.lifecycle.unmount(); }); - it("keeps view-transition-names unique when the thread list and the board panel sidebar are both mounted", async () => { - const captured = await loadApp(); - const list = captured.threadLists[0]!; - const panel = captured.navPanels[0]!; - const options = { - sidebarThreads: { projects, threads: [thread({ id: "thr_steward", displayTitle: "ENG-1: Fix login" })] }, - rpc: { stewardsSidebar: () => sidebarData({ bindings: [binding()] }) } as never, - }; - const listSlot = renderSlot(list, { activeThreadId: null, activeProjectId: "p1", isCompactViewport: false, onNavigate: () => {}, searchQuery: "" }, options); - const panelSlot = renderSlot({ component: panel.experimental_sidebar! }, { subPath: "p1/ENG-1" }, options); - // Both slots render into the same document, so wait on each container. - await waitFor(() => expect(listSlot.container.querySelector("[style*='view-transition-name']")).not.toBeNull()); - await waitFor(() => expect(panelSlot.container.querySelector("[style*='view-transition-name']")).not.toBeNull()); - const names = Array.from(document.querySelectorAll("[style]")) - .map((el) => el.style.viewTransitionName) - .filter((name) => name !== ""); - expect(names.length).toBeGreaterThan(0); - expect(new Set(names).size).toBe(names.length); - listSlot.lifecycle.unmount(); - panelSlot.lifecycle.unmount(); - }); - it("eases glyph and phrase colours and rotates both chevrons", async () => { const slot = await renderSidebar({ sidebarThreads: {