From 824298f414f33bc9c151be5556bbaedd4d35e9ec Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 7 May 2026 15:20:58 -0500 Subject: [PATCH] Route sidebar search icon directly to search (#5440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Operators use the sidebar as their primary board navigation surface > - The board now has a dedicated search page, so the header search icon should behave as normal navigation instead of only dispatching a command-palette shortcut > - The Work nav also had a separate Search row, which duplicated the always-visible header search affordance > - This pull request keeps search one click away while making it a direct `/search` link and reducing sidebar nav noise > - The benefit is a smaller, clearer sidebar with search still accessible from the top-level chrome ## What Changed - Changed the sidebar header search icon into a direct `NavLink` to `/search`. - Removed the duplicate `Search` row from the Work navigation section. - Added focused Sidebar coverage that asserts the header search link target and confirms Search is not rendered in the Work nav. - Refactored the Sidebar test setup helper to avoid repeating the React Query wrapper across tests. ## Verification - `pnpm install --frozen-lockfile` in the PR worktree so workspace package symlinks existed for test execution. This completed with existing plugin SDK bin warnings for missing built artifacts. - `pnpm exec vitest run ui/src/components/Sidebar.test.tsx` — 3 passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. ## Risks - Low: this changes a sidebar navigation affordance only. Users who previously clicked the header icon now land on the full search page instead of opening the command-palette shortcut path. - Low: removing the Work nav Search row could affect users who expected Search in that section, but the icon remains in the fixed sidebar header and is covered by a targeted DOM test. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent, GPT-5 model family (`gpt-5`), tool-enabled Paperclip heartbeat environment. Context window and internal reasoning mode are not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots or equivalent focused UI verification - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../IssueScheduledRetryCard.test.tsx | 48 ++++++++------- ui/src/components/Sidebar.test.tsx | 58 +++++++++++-------- ui/src/components/Sidebar.tsx | 14 ++--- 3 files changed, 66 insertions(+), 54 deletions(-) diff --git a/ui/src/components/IssueScheduledRetryCard.test.tsx b/ui/src/components/IssueScheduledRetryCard.test.tsx index cd1078b2..d3f6e22c 100644 --- a/ui/src/components/IssueScheduledRetryCard.test.tsx +++ b/ui/src/components/IssueScheduledRetryCard.test.tsx @@ -63,13 +63,13 @@ function buildRetryResponse(outcome: IssueRetryNowOutcome) { }; } -async function flushAll() { - for (let i = 0; i < 4; i += 1) { - // eslint-disable-next-line no-await-in-loop +async function waitForUi(assertion: () => void) { + await vi.waitFor(async () => { await act(async () => { await Promise.resolve(); }); - } + assertion(); + }); } function renderWithProviders(ui: ReactNode) { @@ -174,11 +174,12 @@ describe("IssueScheduledRetryCard", () => { act(() => { button!.click(); }); - await flushAll(); - expect(retryNowMock).toHaveBeenCalledWith("issue-1"); - const finalButton = getRetryNowButton(); - expect(finalButton!.textContent ?? "").toContain("Promoted"); - expect(finalButton!.disabled).toBe(true); + await waitForUi(() => { + expect(retryNowMock).toHaveBeenCalledWith("issue-1"); + const finalButton = getRetryNowButton(); + expect(finalButton!.textContent ?? "").toContain("Promoted"); + expect(finalButton!.disabled).toBe(true); + }); }); it("shows already promoted state when backend reports duplicate click", async () => { @@ -189,9 +190,10 @@ describe("IssueScheduledRetryCard", () => { act(() => { getRetryNowButton()!.click(); }); - await flushAll(); - expect(getRetryNowButton()!.textContent ?? "").toContain("Already promoted"); - expect(container.querySelector('[data-testid="issue-scheduled-retry-error-band"]')).toBeNull(); + await waitForUi(() => { + expect(getRetryNowButton()!.textContent ?? "").toContain("Already promoted"); + expect(container.querySelector('[data-testid="issue-scheduled-retry-error-band"]')).toBeNull(); + }); }); it("renders an inline error band on backend failure", async () => { @@ -202,11 +204,12 @@ describe("IssueScheduledRetryCard", () => { act(() => { getRetryNowButton()!.click(); }); - await flushAll(); - const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]'); - expect(band).not.toBeNull(); - expect((band?.textContent ?? "")).toContain("Server error"); - expect(getRetryNowButton()!.disabled).toBe(false); + await waitForUi(() => { + const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]'); + expect(band).not.toBeNull(); + expect((band?.textContent ?? "")).toContain("Server error"); + expect(getRetryNowButton()!.disabled).toBe(false); + }); }); it("surfaces gate-suppressed outcome via the inline error band", async () => { @@ -217,10 +220,11 @@ describe("IssueScheduledRetryCard", () => { act(() => { getRetryNowButton()!.click(); }); - await flushAll(); - const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]'); - expect(band).not.toBeNull(); - expect((band?.textContent ?? "")).toContain("Promotion suppressed"); - expect(getRetryNowButton()!.disabled).toBe(false); + await waitForUi(() => { + const band = container.querySelector('[data-testid="issue-scheduled-retry-error-band"]'); + expect(band).not.toBeNull(); + expect((band?.textContent ?? "")).toContain("Promotion suppressed"); + expect(getRetryNowButton()!.disabled).toBe(false); + }); }); }); diff --git a/ui/src/components/Sidebar.test.tsx b/ui/src/components/Sidebar.test.tsx index 6bfcd9ae..d4192064 100644 --- a/ui/src/components/Sidebar.test.tsx +++ b/ui/src/components/Sidebar.test.tsx @@ -95,6 +95,24 @@ async function flushReact() { describe("Sidebar", () => { let container: HTMLDivElement; + async function renderSidebar() { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + return root; + } + beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); @@ -107,21 +125,23 @@ describe("Sidebar", () => { vi.clearAllMocks(); }); - it("does not flash the Workspaces link while experimental settings are loading", async () => { - mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); - const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + it("links the top search icon to the search page without showing Search in Work nav", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + const root = await renderSidebar(); + + const topSearchLink = container.querySelector('a[aria-label="Search"]'); + expect(topSearchLink?.getAttribute("href")).toBe("/search"); + const workLinks = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim()); + expect(workLinks).not.toContain("Search"); await act(async () => { - root.render( - - - , - ); + root.unmount(); }); - await flushReact(); + }); + + it("does not flash the Workspaces link while experimental settings are loading", async () => { + mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); + const root = await renderSidebar(); expect(container.textContent).not.toContain("Workspaces"); @@ -132,19 +152,7 @@ describe("Sidebar", () => { it("shows the Workspaces link when isolated workspaces are enabled", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); - const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - await act(async () => { - root.render( - - - , - ); - }); - await flushReact(); + const root = await renderSidebar(); const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces"); expect(link?.getAttribute("href")).toBe("/workspaces"); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index f202b6d9..a0bc7195 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -14,6 +14,7 @@ import { Settings, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; +import { NavLink } from "@/lib/router"; import { SidebarSection } from "./SidebarSection"; import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarProjects } from "./SidebarProjects"; @@ -45,10 +46,6 @@ export function Sidebar() { const liveRunCount = liveRuns?.length ?? 0; const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; - function openSearch() { - document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })); - } - const pluginContext = { companyId: selectedCompanyId, companyPrefix: selectedCompany?.issuePrefix ?? null, @@ -60,12 +57,16 @@ export function Sidebar() {
@@ -99,7 +100,6 @@ export function Sidebar() { - {showWorkspacesLink ? (