mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## 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>
215 lines
12 KiB
TypeScript
215 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|