From 031003c5e1e7e25938f8c11c804c2b9363a4ec83 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Thu, 13 Aug 2026 12:18:49 -0700 Subject: [PATCH] fix(ui): search parent-issue picker on the server (#11334) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board UI lets an operator choose a parent issue for an issue > - The parent picker loads a priority-first page and filters that page in the browser > - A medium-priority or low-priority issue past the page limit never enters the picker > - This pull request sends typed parent-picker text to the server and keeps the picker exclusions > - The benefit is that the operator can select valid parent issues beyond the default page ## Linked Issues or Issue Description This pull request supersedes [#6193](https://github.com/paperclipai/paperclip/pull/6193), whose old file path no longer matches the current component tree. **What happened?** The parent picker fetched one default issue page and filtered it in the browser. The default page sorts by priority and caps the result at 500 issues. Valid medium-priority and low-priority parent issues beyond that page stayed hidden. **Expected behavior** The parent picker must search the server when the operator types text. It must show matching issues beyond the default page while it keeps the current issue and descendant exclusions. **Steps to reproduce** 1. Open an issue in a company with more than 500 issues. 2. Open the parent picker and type the name of a medium-priority or low-priority issue beyond the default page. 3. Observe that the picker does not show the matching issue. **Paperclip version or commit** Commit `c6965bd0237fd9536b41f1495e2a4bb252afcde7`. **Deployment mode** Local dev (`pnpm dev`). ## What Changed - Send parent-picker searches to the issue list endpoint with `q` and a bounded `limit` of 50. - Keep the empty-search list, cycle exclusions, and current sort behavior. - Add a component test for a low-priority match hidden by the default page. ## Verification - Run `pnpm vitest run ui/src/components/IssueProperties.test.tsx`. - Confirm that all 53 tests pass. - Confirm that the new test checks `{ q, limit: 50 }` and the matching issue. ## Risks - Low risk. The change affects only parent-picker search requests. - The server search uses the existing issue list query and does not change stored data. ## Model Used Codex, GPT-5, with tool use and code execution. The model assisted with the change and test. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- ui/src/components/IssueProperties.test.tsx | 76 +++++++++++++++++++ .../issue-properties/IssueProperties.tsx | 35 ++++++--- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index c4c37a9053..0427bfb6bc 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -2039,6 +2039,82 @@ describe("IssueProperties", () => { act(() => rerenderedRoot.unmount()); }); + + it("searches the server for parent candidates so a lower-priority match past the default page stays selectable", async () => { + const onUpdate = vi.fn(); + // The default list is priority-first and caps at 500 rows, so a low-priority + // match past that cap never enters the client list. Model that gap: the default + // page returns only a high-priority candidate, and the server search with `q` + // returns the low-priority match that the default page hides. + const defaultPageCandidate = createIssue({ + id: "issue-2", + identifier: "PAP-2", + title: "High priority candidate", + status: "in_progress", + priority: "high", + }); + const lowPriorityMatch = createIssue({ + id: "issue-900", + identifier: "PAP-900", + title: "Low priority needle", + status: "todo", + priority: "low", + }); + mockIssuesApi.list.mockImplementation((_companyId: string, filters?: { q?: string; limit?: number }) => { + if (filters?.q === "needle") return Promise.resolve([lowPriorityMatch]); + return Promise.resolve([defaultPageCandidate]); + }); + + const root = renderProperties(container, { + issue: createIssue(), + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const parentTrigger = findRowTrigger(container, "Parent"); + expect(parentTrigger).not.toBeUndefined(); + await act(async () => { + parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + // With an empty search the picker shows the default page. The low-priority match is absent. + await waitForAssertion(() => { + expect(container.textContent).toContain("PAP-2 High priority candidate"); + }); + expect(container.textContent).not.toContain("PAP-900 Low priority needle"); + + const searchInput = container.querySelector('input[placeholder="Search tasks..."]') as HTMLInputElement | null; + expect(searchInput).not.toBeNull(); + + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + nativeSetter?.call(searchInput, "needle"); + searchInput!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // The typed text re-queries the server with `q`, and the low-priority match now appears. + await waitForAssertion(() => { + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "needle", limit: 50 }); + expect(container.textContent).toContain("PAP-900 Low priority needle"); + expect(container.textContent).not.toContain("PAP-2 High priority candidate"); + }); + + const candidateButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("PAP-900 Low priority needle")); + expect(candidateButton).not.toBeUndefined(); + + await act(async () => { + candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-900" }); + + act(() => root.unmount()); + }); + it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => { const onUpdate = vi.fn(); const root = renderProperties(container, { diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index ff32ed668c..5fa9ada2be 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -287,6 +287,7 @@ export function IssueProperties({ const [watchdogAgentInput, setWatchdogAgentInput] = useState(issue.watchdog?.watchdogAgentId ?? ""); const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? ""); const normalizedBlockedBySearch = blockedBySearch.trim(); + const normalizedParentSearch = parentSearch.trim(); useEffect(() => { setBlockedByExpanded(false); @@ -349,6 +350,17 @@ export function IssueProperties({ enabled: !!companyId && blockedByOpen && normalizedBlockedBySearch.length > 0, }); + const { data: searchedParentIssues, isFetching: isFetchingSearchedParentIssues } = useQuery({ + queryKey: companyId + ? queryKeys.issues.search(companyId, normalizedParentSearch, undefined, ISSUE_BLOCKER_SEARCH_LIMIT) + : ["issues", "blocker-search", normalizedParentSearch, ISSUE_BLOCKER_SEARCH_LIMIT], + queryFn: () => issuesApi.list(companyId!, { + q: normalizedParentSearch, + limit: ISSUE_BLOCKER_SEARCH_LIMIT, + }), + enabled: !!companyId && parentOpen && normalizedParentSearch.length > 0, + }); + const createLabel = useMutation({ mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data), onSuccess: async (created) => { @@ -1898,22 +1910,22 @@ export function IssueProperties({ ) : undefined; - const parentOptions = (allIssues ?? []) + const parentSearchActive = normalizedParentSearch.length > 0; + // When the user types, search on the server. The default list caps at 500 rows + // and sorts priority-first, so a medium-priority or low-priority match past that + // cap never enters the client list. A server query with `q` still finds it. + const parentSourceIssues = parentSearchActive ? searchedParentIssues : allIssues; + const parentOptions = (parentSourceIssues ?? []) .filter((candidate) => candidate.id !== issue.id) .filter((candidate) => !descendantIssueIds.has(candidate.id)) - .filter((candidate) => { - if (!parentSearch.trim()) return true; - const query = parentSearch.toLowerCase(); - return ( - (candidate.identifier ?? "").toLowerCase().includes(query) || - candidate.title.toLowerCase().includes(query) - ); - }) .sort((a, b) => { const aLabel = `${a.identifier ?? ""} ${a.title}`.trim(); const bLabel = `${b.identifier ?? ""} ${b.title}`.trim(); return aLabel.localeCompare(bLabel); }); + const parentOptionsLoading = parentOpen && ( + parentSearchActive ? isFetchingSearchedParentIssues : isFetchingIssuePickerIssues + ); const parentContent = ( <> ))} + {parentOptionsLoading ? ( +
Searching tasks...
+ ) : parentOptions.length === 0 ? ( +
No matching tasks.
+ ) : null} );