Guard subissue tree against broad issue results

This commit is contained in:
Dotta committed 2026-04-24 00:21:07 -05:00
1 parent ff7946f26e
commit 5e2d7bca4a
4 files changed
+97 -11

No files matched your search

+23 -8
View File
@@ -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<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
searchWithinLoadedIssues?: boolean;
baseCreateIssueDefaults?: Record<string, unknown>;
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<string, Issue>();
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 && <PageSkeleton variant="issues-list" />}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
{!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (
<p className="text-xs text-muted-foreground">
Showing up to {ISSUE_SEARCH_RESULT_LIMIT} matches. Refine the search to narrow further.
</p>
+31 -1
View File
@@ -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"]);
});
});
+36
View File
@@ -34,3 +34,39 @@ export function countDescendants(id: string, childMap: Map<string, Issue[]>): 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<string, Issue[]>();
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<string>([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;
}
+7 -2
View File
@@ -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}