Files
PaperClipAI/ui/src/pages/Projects.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

257 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { EntityRow } from "../components/EntityRow";
import { ProjectTile } from "../components/ProjectTile";
import { StatusBadge } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { StarToggle } from "../components/StarToggle";
import { ArtifactEmptyGraphic } from "@/components/artifacts/v1/ArtifactEmptyGraphic";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { QueryErrorState, useQueryView } from "../components/QueryView";
import { formatDate, formatNumber, formatProjectBudget, projectUrl } from "../lib/utils";
import {
isStarred,
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { ProjectsToolbar, type ProjectSortField, type ProjectSortDir } from "@/components/ProjectsToolbar";
import { Hexagon } from "lucide-react";
import { Lock } from "lucide-react";
import { Card } from "@/components/ui/card";
import { companyProjectsQuery } from "@/lib/company-projects-query";
function compareProjectNames(left: Project, right: Project) {
const nameDiff = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
return nameDiff !== 0 ? nameDiff : left.id.localeCompare(right.id);
}
function projectTime(value: Date | string | null | undefined): number | null {
if (!value) return null;
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : null;
}
function compareOptionalTime(
left: Date | string | null | undefined,
right: Date | string | null | undefined,
sortDir: ProjectSortDir,
) {
const leftTime = projectTime(left);
const rightTime = projectTime(right);
if (leftTime === null && rightTime === null) return 0;
if (leftTime === null) return 1;
if (rightTime === null) return -1;
return sortDir === "asc" ? leftTime - rightTime : rightTime - leftTime;
}
function sortProjects(projects: Project[], sortField: ProjectSortField, sortDir: ProjectSortDir) {
return [...projects].sort((left, right) => {
let comparison = 0;
if (sortField === "name") {
comparison = compareProjectNames(left, right);
return sortDir === "asc" ? comparison : -comparison;
}
if (sortField === "updated") comparison = compareOptionalTime(left.updatedAt, right.updatedAt, sortDir);
else if (sortField === "created") comparison = compareOptionalTime(left.createdAt, right.createdAt, sortDir);
else comparison = compareOptionalTime(left.targetDate, right.targetDate, sortDir);
if (comparison === 0) comparison = compareProjectNames(left, right);
return comparison;
});
}
export function Projects() {
const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const [query, setQuery] = useState("");
const [sortField, setSortField] = useState<ProjectSortField>("name");
const [sortDir, setSortDir] = useState<ProjectSortDir>("asc");
useEffect(() => {
setBreadcrumbs([{ label: "Projects" }]);
}, [setBreadcrumbs]);
const projectsQuery = useQuery({
...companyProjectsQuery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: allProjects, isLoading } = projectsQuery;
const projectsView = useQueryView(projectsQuery);
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const projects = useMemo(
() => allProjects ?? [],
[allProjects],
);
const sortedProjects = useMemo(
() => {
const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
const matching = projects.filter(project => {
const text = `${project.name} ${project.description ?? ""}`.toLocaleLowerCase();
return words.every(word => text.includes(word));
});
return sortProjects(matching, sortField, sortDir);
},
[projects, query, sortDir, sortField],
);
const groupedProjects = useMemo(() => {
const groups = {
mine: [] as typeof sortedProjects,
other: [] as typeof sortedProjects,
};
for (const project of sortedProjects) {
const state = resourceMembershipState(membershipsQuery.data, "project", project.id);
if (state === "left") groups.other.push(project);
else groups.mine.push(project);
}
return groups;
}, [membershipsQuery.data, sortedProjects]);
if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select an organization to view projects." />;
}
// Keep a loaded list on screen through a failed refetch; only a real
// failure, or an outage before the first load, replaces the page.
if (isLoading || projectsView.kind === "reconnecting") {
return <PageSkeleton variant="list" />;
}
if (projectsView.kind === "error") {
return (
<QueryErrorState
size="page"
error={projectsView.error}
action="load projects"
onRetry={projectsView.retry}
retrying={projectsView.isFetching}
/>
);
}
return (
<div className="space-y-4">
<ProjectsToolbar query={query} onQueryChange={setQuery} sortField={sortField} sortDir={sortDir}
onSortChange={(field, direction) => { setSortField(field); setSortDir(direction); }} onNewProject={openNewProject} />
{!isLoading && projects.length === 0 && (
<EmptyState
icon={Hexagon}
graphic={<ArtifactEmptyGraphic stack />}
message="No projects yet."
action="Create project"
hideActionIcon
onAction={openNewProject}
/>
)}
{projects.length > 0 && sortedProjects.length === 0 && <p className="text-sm text-muted-foreground">No projects match your search.</p>}
{sortedProjects.length > 0 && (
<div className="space-y-6">
{([
["My Projects", groupedProjects.mine],
["Other Projects", groupedProjects.other],
] as const).map(([label, sectionProjects]) => {
if (sectionProjects.length === 0) return null;
return (
<section key={label} className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">{label}</h2>
<span className="text-xs text-muted-foreground">
{sectionProjects.length} project{sectionProjects.length === 1 ? "" : "s"}
</span>
</div>
<Card className="block py-0 overflow-hidden divide-y divide-border">
{sectionProjects.map((project) => {
const state = resourceMembershipState(membershipsQuery.data, "project", project.id);
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
const starPending = pending && membershipMutation.variables?.starred !== undefined;
const joinLeavePending = pending && membershipMutation.variables?.starred === undefined;
const starred = isStarred(membershipsQuery.data, "project", project.id);
return (
<EntityRow
key={project.id}
leading={<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="sm" />}
title={project.name}
subtitle={project.description ?? undefined}
reserveSubtitleSpace
to={projectUrl(project)}
className={state === "left" ? "group text-foreground/55" : "group"}
trailing={
<div className="flex items-center gap-3">
{project.visibility === "private" ? <Lock className="size-4 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
<span
className="hidden text-xs text-muted-foreground tabular-nums sm:inline"
title={`${formatNumber(project.taskCount ?? 0)} task${(project.taskCount ?? 0) === 1 ? "" : "s"}`}
>
{formatNumber(project.taskCount ?? 0)} task{(project.taskCount ?? 0) === 1 ? "" : "s"}
</span>
{project.budget && (
<span className="hidden text-xs text-muted-foreground tabular-nums sm:inline">
{formatProjectBudget(project.budget)}
</span>
)}
{project.targetDate && (
<span className="hidden text-xs text-muted-foreground md:inline">
{formatDate(project.targetDate)}
</span>
)}
<StatusBadge status={project.status} />
<MembershipAction
state={state}
pending={joinLeavePending}
pendingState={joinLeavePending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "left",
})}
/>
<StarToggle
size="row"
starred={starred}
pending={starPending}
resourceName={project.name}
onToggle={(next) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
starred: next,
})}
/>
</div>
}
/>
);
})}
</Card>
</section>
);
})}
</div>
)}
</div>
);
}