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 <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-08-13 12:18:49 -07:00 committed by GitHub
parent 403fcefb97
commit 031003c5e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 102 additions and 9 deletions

View File

@ -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, {

View File

@ -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({
<ArrowUpRight className="h-3 w-3" />
</Link>
) : 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 = (
<>
<input
@ -1955,6 +1967,11 @@ export function IssueProperties({
</span>
</button>
))}
{parentOptionsLoading ? (
<div className="px-2 py-2 text-xs text-muted-foreground">Searching tasks...</div>
) : parentOptions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No matching tasks.</div>
) : null}
</div>
</>
);