diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index a444a2dfd99..c19d76a9b25 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -177,6 +177,15 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { return sorted; } +function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolean { + if (!normalizedSearch) return true; + return [ + issue.identifier, + issue.title, + issue.description, + ].some((value) => value?.toLowerCase().includes(normalizedSearch)); +} + /* ── Component ── */ interface Agent { @@ -208,6 +217,7 @@ interface IssuesListProps { initialWorkspaces?: string[]; initialSearch?: string; searchFilters?: Omit; + searchWithinLoadedIssues?: boolean; baseCreateIssueDefaults?: Record; createIssueLabel?: string; enableRoutineVisibilityFilter?: boolean; @@ -293,6 +303,7 @@ export function IssuesList({ initialWorkspaces, initialSearch, searchFilters, + searchWithinLoadedIssues = false, baseCreateIssueDefaults, createIssueLabel, enableRoutineVisibilityFilter = false, @@ -393,7 +404,7 @@ export function IssuesList({ ...searchFilters, ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), }), - enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, + enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues, placeholderData: (previousData) => previousData, }); const boardIssueQueries = useQueries({ @@ -417,7 +428,7 @@ export function IssuesList({ limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT, ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), }), - enabled: !!selectedCompanyId && viewState.viewMode === "board", + enabled: !!selectedCompanyId && viewState.viewMode === "board" && !searchWithinLoadedIssues, placeholderData: (previousData: Issue[] | undefined) => previousData, })), }); @@ -598,7 +609,7 @@ export function IssuesList({ }, [issues]); const boardIssues = useMemo(() => { - if (viewState.viewMode !== "board") return null; + if (viewState.viewMode !== "board" || searchWithinLoadedIssues) return null; const merged = new Map(); let isPending = false; for (const query of boardIssueQueries) { @@ -609,13 +620,17 @@ export function IssuesList({ } if (merged.size > 0) return [...merged.values()]; return isPending ? issues : []; - }, [boardIssueQueries, issues, viewState.viewMode]); + }, [boardIssueQueries, issues, searchWithinLoadedIssues, viewState.viewMode]); const filtered = useMemo(() => { - const sourceIssues = boardIssues ?? (normalizedIssueSearch.length > 0 ? searchedIssues : issues); - const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter); + const useRemoteSearch = normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues; + const sourceIssues = boardIssues ?? (useRemoteSearch ? searchedIssues : issues); + const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues + ? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch)) + : sourceIssues; + const filteredByControls = applyIssueFilters(searchScopedIssues, viewState, currentUserId, enableRoutineVisibilityFilter); return sortIssues(filteredByControls, viewState); - }, [boardIssues, issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); + }, [boardIssues, issues, searchedIssues, searchWithinLoadedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -913,7 +928,7 @@ export function IssuesList({ {isLoading && } {error &&

{error.message}

} - {normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && ( + {!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (

Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further.

diff --git a/ui/src/lib/issue-tree.test.ts b/ui/src/lib/issue-tree.test.ts index 75d38f874a1..955f3a0d989 100644 --- a/ui/src/lib/issue-tree.test.ts +++ b/ui/src/lib/issue-tree.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Issue } from "@paperclipai/shared"; -import { buildIssueTree, countDescendants } from "./issue-tree"; +import { buildIssueTree, countDescendants, filterIssueDescendants } from "./issue-tree"; function makeIssue(id: string, parentId: string | null = null): Issue { return { @@ -128,3 +128,33 @@ describe("countDescendants", () => { expect(countDescendants("nonexistent", childMap)).toBe(0); }); }); + +describe("filterIssueDescendants", () => { + it("returns only children and deeper descendants of the requested root", () => { + const root = makeIssue("root"); + const child = makeIssue("child", "root"); + const grandchild = makeIssue("grandchild", "child"); + const unrelatedParent = makeIssue("other"); + const unrelatedChild = makeIssue("other-child", "other"); + + expect(filterIssueDescendants("root", [ + root, + child, + grandchild, + unrelatedParent, + unrelatedChild, + ]).map((issue) => issue.id)).toEqual(["child", "grandchild"]); + }); + + it("handles stale broad issue-list responses without requiring the root in the list", () => { + const child = makeIssue("child", "root"); + const grandchild = makeIssue("grandchild", "child"); + const globalIssue = makeIssue("global"); + + expect(filterIssueDescendants("root", [ + globalIssue, + child, + grandchild, + ]).map((issue) => issue.id)).toEqual(["child", "grandchild"]); + }); +}); diff --git a/ui/src/lib/issue-tree.ts b/ui/src/lib/issue-tree.ts index e88709ed120..25707053146 100644 --- a/ui/src/lib/issue-tree.ts +++ b/ui/src/lib/issue-tree.ts @@ -34,3 +34,39 @@ export function countDescendants(id: string, childMap: Map): nu const children = childMap.get(id) ?? []; return children.reduce((sum, c) => sum + 1 + countDescendants(c.id, childMap), 0); } + +/** + * Filters a flat issue list to only descendants of `rootId`. + * + * This is intentionally useful even when the list contains unrelated issues: + * stale servers may ignore newer descendant-scoped query params, and the UI + * must still avoid rendering global issue data in a sub-issue panel. + */ +export function filterIssueDescendants(rootId: string, items: Issue[]): Issue[] { + const childrenByParentId = new Map(); + for (const item of items) { + if (!item.parentId) continue; + const siblings = childrenByParentId.get(item.parentId) ?? []; + siblings.push(item); + childrenByParentId.set(item.parentId, siblings); + } + + const descendants: Issue[] = []; + const seen = new Set([rootId]); + let frontier = [rootId]; + + while (frontier.length > 0) { + const nextFrontier: string[] = []; + for (const parentId of frontier) { + for (const child of childrenByParentId.get(parentId) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + descendants.push(child); + nextFrontier.push(child.id); + } + } + frontier = nextFrontier; + } + + return descendants; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 58840877519..a4345e0a616 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -98,6 +98,7 @@ import { Textarea } from "@/components/ui/textarea"; import { formatIssueActivityAction } from "@/lib/activity-format"; import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key"; import { shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues"; +import { filterIssueDescendants } from "../lib/issue-tree"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { Activity as ActivityIcon, @@ -1286,8 +1287,11 @@ export function IssueDetail() { [issue?.project, issue?.projectId, orderedProjects], ); const childIssues = useMemo( - () => [...rawChildIssues].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()), - [rawChildIssues], + () => { + const descendants = issue?.id ? filterIssueDescendants(issue.id, rawChildIssues) : rawChildIssues; + return [...descendants].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + }, + [issue?.id, rawChildIssues], ); const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]); const issuePanelKey = useMemo( @@ -3134,6 +3138,7 @@ export function IssueDetail() { viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`} issueLinkState={resolvedIssueDetailState ?? location.state} searchFilters={{ descendantOf: issue.id }} + searchWithinLoadedIssues baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)} createIssueLabel="Sub-issue" onUpdateIssue={handleChildIssueUpdate}