forked from farhoodlabs/paperclip
Route sidebar search icon directly to search (#5440)
## 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Sidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
const root = await renderSidebar();
|
||||
|
||||
const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Workspaces");
|
||||
expect(link?.getAttribute("href")).toBe("/workspaces");
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||
<SidebarCompanyMenu />
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
onClick={openSearch}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<NavLink to="/search">
|
||||
<Search className="h-4 w-4" />
|
||||
</NavLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +100,6 @@ export function Sidebar() {
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/search" label="Search" icon={Search} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
{showWorkspacesLink ? (
|
||||
|
||||
Reference in New Issue
Block a user