Files
PaperClipAI/ui/src/pages/Cases.tsx
T
DottaandClaude Opus 5.5 b184e40108 perf(ui): serve every project list from one includeArchived read (#20395)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI reads the company project list in many places: the
sidebar, starred projects, the command palette, the new-task dialog, and
archive-aware pages such as the Mine inbox
> - Active-only consumers read `GET /projects`, and archive-aware ones
read `GET /projects?includeArchived=true`, under two React Query keys
> - A Mine load therefore fetched the same large payload twice (580–590
KB each on our dev instance)
> - The two responses differ only by archived rows; the server's active
list is exactly the rows where `archivedAt` is null
> - This pull request makes every consumer share the
`includeArchived=true` query, and active-only consumers filter it with
`select`
> - The benefit is one project read per page instead of two

## Linked Issues or Issue Description

**What existing behavior does this improve?**
Duplicate project list reads on page load.

**Subsystem affected**
Board UI project queries (`ui/src/lib/company-projects-query.ts` and its
consumers).

**Current behavior**
A cold Mine load sends both `/companies/:id/projects` (sidebar) and
`/companies/:id/projects?includeArchived=true` (inbox).

**Proposed behavior**
One `includeArchived=true` read serves both. Active-only consumers apply
`selectActiveProjects`.

**Reason and benefit**
The project list is one of the largest shell payloads. Reading it once
saves a large response on every page that has both kinds of consumer.

## What Changed

- New `companyProjectsQuery(companyId, { includeArchived? })` in
`ui/src/lib/company-projects-query.ts`. It always uses the
`includeArchived: true` key and fetch. For active-only use it adds a
stable, module-level `select` that keeps rows without `archivedAt`.
- 22 files that read the active list now spread
`companyProjectsQuery(...)`, and so do the plugin SDK project picker and
16 archive-aware pages that wrote the shared key and fetcher inline.
Their `enabled` and other options are unchanged. `OnboardingWizard`'s
one imperative read is unchanged. `pages/Inbox.tsx` already reads the
same key and fetcher, so it shares the entry without an edit.
- Invalidation: project mutations and live events invalidate
`queryKeys.projects.all(companyId)`, which matches the shared key. The
onboarding wizard invalidated the old active-only key; it now
invalidates `projects.all`.
- Tests: new `company-projects-query.test.tsx` (one request for both
consumers, correct filtering, and one refetch after `projects.all`
invalidation). Three existing tests (`CommandPalette`, `CompanyExport`,
`GitHubBotManagement`) now seed or expect the shared key.
- New browser check
`tests/perf/collections/shared-project-list.spec.ts`: after the Mine
inbox read completes, the page has sent no active-only project request.

## Verification

- `pnpm verify` passes.
- `ui`: the consumer suites (`CoordinatorSidebar`, `SidebarProjects`,
`SidebarStarredProjects`, `CommandPalette`, `NewIssueDialog`,
`AgentWorkPanels`, `CoordinatorNeedsYou`, `IssueDetail`,
`ProjectDetail`, `Projects`, `CompanyExport`, `ChatExecutionDefaults`,
`GatewaysList`, `Search`, `IssueProperties`, `persisted-query-cache`)
pass.
- Browser check:
`PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec
playwright test --config tests/perf/collections/playwright.config.ts
shared-project-list.spec.ts`. It passes on this branch and fails on
`master`.

## Risks

- A page that needs only active projects now downloads archived rows
too. For most companies that is a small difference; the duplicate read
it removes is a full copy.
- The server does not order either list, and consumers sort for display,
so filtering on the client keeps behavior.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, browser). 1M-token context window. Extended
thinking was off.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:42:47 -06:00

215 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { companyProjectsQuery } from "@/lib/company-projects-query";
import { useQueryView } from "@/components/QueryView";
import { useEffect, useLayoutEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { FolderOpen, Search } from "lucide-react";
import { casesApi, CASE_STATUSES, type ListCasesParams } from "@/api/cases";
import { CaseListRow } from "@/components/cases/CaseListRow";
import { CaseListControls } from "@/components/cases/CaseListControls";
import { CollectionToolbar } from "@/components/CollectionToolbar";
import { IssueGroupHeader } from "@/components/IssueGroupHeader";
import { CASE_STATUS_LABELS } from "@/components/cases/CaseStatus";
import { EmptyState } from "@/components/EmptyState";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SelectPopover } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { queryKeys } from "@/lib/queryKeys";
import { useSearchParams } from "@/lib/router";
import { caseListView, DEFAULT_CASE_COLUMNS } from "@/lib/case-list-view";
const PAGE_SIZE = 50;
const ALL = "all";
type FilterOption = { value: string; label: string };
function CaseFilter({ label, value, options, onChange, disabled }: {
label: string;
value: string;
options: FilterOption[];
onChange: (value: string) => void;
disabled?: boolean;
}) {
return <div className="space-y-1">
<span className="text-xs text-muted-foreground">{label}</span>
<SelectPopover aria-label={`Filter by ${label.toLowerCase()}`} value={value}
options={options} onValueChange={onChange} disabled={disabled} className="text-sm data-[size=default]:h-9" />
</div>;
}
export function Cases() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [params, setParams] = useSearchParams();
const latestParams = useRef(params);
useLayoutEffect(() => { latestParams.current = params; }, [params]);
const query = params.get("q") ?? "";
const view = caseListView(params);
const requestedStatus = params.get("status") ?? ALL;
const status = CASE_STATUSES.includes(requestedStatus as typeof CASE_STATUSES[number]) ? requestedStatus : ALL;
const type = params.get("type") || ALL;
const project = params.get("project") || ALL;
const requestedPage = Number(params.get("page") ?? 1);
const page = Number.isSafeInteger(requestedPage) && requestedPage > 0 && requestedPage <= 2_001 ? requestedPage : 1;
const offset = (page - 1) * PAGE_SIZE;
const hasFilters = query !== "" || status !== ALL || type !== ALL || project !== ALL;
const activeFilterCount = [status, type, project].filter(value => value !== ALL).length;
const filters: ListCasesParams = {
q: query.trim() || undefined,
status: status === ALL ? undefined : status,
type: type === ALL ? undefined : type,
projectId: project === ALL || project === "none" ? undefined : project,
includeNoProject: project === "none" || undefined,
limit: PAGE_SIZE + 1,
offset,
...(view.sortField !== "updated" ? { sortField: view.sortField } : {}),
...(view.sortDir !== "desc" ? { sortDir: view.sortDir } : {}),
...(view.groupBy !== "none" ? { groupBy: view.groupBy } : {}),
};
useEffect(() => setBreadcrumbs([{ label: "Cases" }]), [setBreadcrumbs]);
const casesQuery = useQuery({
queryKey: [...queryKeys.cases.list(selectedCompanyId ?? ""), filters],
queryFn: () => casesApi.list(selectedCompanyId!, filters),
enabled: !!selectedCompanyId,
placeholderData: (previous, previousQuery) => previousQuery?.queryKey[1] === selectedCompanyId ? previous : undefined,
});
const casesQueryView = useQueryView(casesQuery);
const casesWaiting = ["loading", "reconnecting"].includes(casesQueryView.kind);
const typesQuery = useQuery({
queryKey: [...queryKeys.cases.list(selectedCompanyId ?? ""), "types"],
queryFn: () => casesApi.listTypes(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const typesQueryView = useQueryView(typesQuery);
const projectsQuery = useQuery({
...companyProjectsQuery(selectedCompanyId ?? "", { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const projectsQueryView = useQueryView(projectsQuery);
const records = (casesQuery.data ?? []).slice(0, PAGE_SIZE);
const hasNext = (casesQuery.data?.length ?? 0) > PAGE_SIZE;
const projectNames = new Map((projectsQuery.data ?? []).map(item => [item.id, item.name]));
const typeOptions = [...new Set([...(typesQuery.data ?? []), ...(type !== ALL ? [type] : [])])].sort();
const groups = new Map<string, { label: string; records: typeof records }>();
for (const record of records) {
const key = view.groupBy === "status" ? record.status : view.groupBy === "type" ? record.caseType
: view.groupBy === "project" ? record.projectId ?? "none" : ALL;
const label = view.groupBy === "status" ? CASE_STATUS_LABELS[record.status]
: view.groupBy === "project" ? record.projectId ? projectNames.get(record.projectId) ?? "Project unavailable" : "No project" : key;
if (!groups.has(key)) groups.set(key, { label, records: [] });
groups.get(key)!.records.push(record);
}
const updateParams = (update: (next: URLSearchParams) => void, replace = false) => {
// Router callbacks each receive the last rendered URL, rather than queued
// updates. Compose rapid controls against the latest requested URL instead.
const next = new URLSearchParams(latestParams.current);
update(next);
latestParams.current = next;
setParams(next, { replace });
};
const setFilter = (key: string, value: string) => {
updateParams(next => {
if (!value || (key !== "q" && value === ALL)) next.delete(key);
else next.set(key, value);
next.delete("page");
}, true);
};
const setPage = (nextPage: number) => {
updateParams(next => {
if (nextPage === 1) next.delete("page");
else next.set("page", String(nextPage));
});
};
const setView = (values: Record<string, string>, resetPage = true) => {
updateParams(next => {
for (const [key, value] of Object.entries(values)) {
if (!value) next.delete(key);
else next.set(key, value);
}
if (resetPage) next.delete("page");
}, true);
};
const clearFilters = () => setView({ q: "", status: "", type: "", project: "" });
if (!selectedCompanyId) return <EmptyState icon={FolderOpen} message="Select a company to view cases." />;
return (
<div className="mx-auto max-w-6xl space-y-4" data-testid="case-pages">
<CollectionToolbar ariaLabel="Case controls" search={<div className="flex min-w-0 items-center gap-3">
<div className="relative min-w-0 flex-1 sm:max-w-xs">
<Search aria-hidden className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input aria-label="Search cases" placeholder="Search cases…" value={query}
data-page-search-target="true" onChange={event => setFilter("q", event.target.value)} className="h-8 pl-8" />
</div>
<p role="status" className="whitespace-nowrap text-xs text-muted-foreground">
{casesWaiting ? "Loading cases…" : casesQuery.isFetching ? "Updating cases…"
: (casesQueryView.kind === "error") ? ""
: page > 1 && records.length > 0 ? `${offset + 1}–${offset + records.length} cases`
: `${records.length}${hasNext ? "+" : ""} ${records.length === 1 && !hasNext ? "case" : "cases"}`}
</p>
</div>} controls={<CaseListControls activeFilterCount={activeFilterCount} columns={view.columns}
onColumnsChange={columns => setView({ columns: columns.join(",") === DEFAULT_CASE_COLUMNS.join(",") ? "" : columns.join(",") || "none" }, false)}
sortField={view.sortField} sortDir={view.sortDir}
onSortChange={(field, dir) => setView({ sort: field === "updated" ? "" : field, dir: dir === "desc" ? "" : dir })}
groupBy={view.groupBy} onGroupChange={group => setView({ group: group === "none" ? "" : group })}
filters={<>
<CaseFilter label="Status" value={status} onChange={value => setFilter("status", value)} options={[
{ value: ALL, label: "All statuses" },
...CASE_STATUSES.map(value => ({ value, label: CASE_STATUS_LABELS[value] })),
]} />
<CaseFilter label="Type" value={type} disabled={typesQuery.isPending || (typesQueryView.kind === "error")}
onChange={value => setFilter("type", value)} options={[
{ value: ALL, label: "All types" },
...typeOptions.map(value => ({ value, label: value })),
]} />
<CaseFilter label="Project" value={project} disabled={projectsQuery.isPending || (projectsQueryView.kind === "error")}
onChange={value => setFilter("project", value)} options={[
{ value: ALL, label: "All projects" },
{ value: "none", label: "No project" },
...(projectsQuery.data ?? []).map(item => ({ value: item.id, label: item.name })),
...(project !== ALL && project !== "none" && !projectNames.has(project)
? [{ value: project, label: "Selected project" }] : []),
]} />
</>} />}
feedback={hasFilters ? <Button variant="ghost" size="sm" onClick={clearFilters}>Clear filters</Button> : undefined} />
{(typesQueryView.kind === "error") || (projectsQueryView.kind === "error") ? <div role="alert" className="flex flex-wrap items-center gap-3 text-sm">
<p>Some case filters couldn’t be loaded.</p>
<Button variant="ghost" size="sm" onClick={() => {
if ((typesQueryView.kind === "error")) void typesQuery.refetch();
if ((projectsQueryView.kind === "error")) void projectsQuery.refetch();
}}>Retry filters</Button>
</div> : null}
{casesWaiting ? <div aria-hidden="true" className="space-y-2">
{Array.from({ length: 6 }, (_, index) => <Skeleton key={index} className="h-16 w-full rounded-none" />)}
</div> : (casesQueryView.kind === "error") ? <div role="alert" className="flex flex-wrap items-center gap-3 py-6 text-sm">
<p>Cases couldn’t be loaded.</p>
<Button variant="outline" size="sm" onClick={() => void casesQuery.refetch()}>Try again</Button>
</div> : records.length === 0 ? (
<EmptyState icon={hasFilters ? Search : FolderOpen}
message={page > 1 ? "No cases on this page" : hasFilters ? "No cases match these filters" : "No cases yet"}
action={page > 1 ? "First page" : hasFilters ? "Clear filters" : undefined} hideActionIcon
onAction={() => page > 1 ? setPage(1) : clearFilters()} />
) : <section aria-label="Case list" aria-busy={casesQuery.isFetching} inert={casesQuery.isPlaceholderData} className="border-t border-border">
{[...groups].map(([key, group]) => <div key={key}>
{view.groupBy !== "none" && <IssueGroupHeader label={group.label} className="border-b border-border px-3 pt-4" />}
{group.records.map(record => <CaseListRow key={record.id} caseData={record} columns={view.columns}
projectName={record.projectId ? projectNames.get(record.projectId) : undefined}
search={params.size ? `?${params}` : ""} />)}
</div>)}
</section>}
{page > 1 || hasNext ? <nav aria-label="Case list pages" className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" disabled={page === 1 || casesQuery.isFetching} onClick={() => setPage(page - 1)}>Previous</Button>
<Button variant="outline" size="sm" disabled={!hasNext || casesQuery.isFetching} onClick={() => setPage(page + 1)}>Next</Button>
</nav> : null}
</div>
);
}