mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 22:56:58 +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>
257 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|