mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +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>
218 lines
8.6 KiB
TypeScript
218 lines
8.6 KiB
TypeScript
import { useCallback, useMemo } from "react";
|
|
import { NavLink, useLocation } from "@/lib/router";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Lock, Loader2, LogOut, MoreHorizontal, Star } from "lucide-react";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
|
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
|
import {
|
|
isStarred,
|
|
starredResourceIds,
|
|
useResourceMembershipMutation,
|
|
useResourceMemberships,
|
|
} from "../hooks/useResourceMemberships";
|
|
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
|
import { ProjectTile } from "./ProjectTile";
|
|
import { StarToggle } from "./StarToggle";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import type { Project } from "@paperclipai/shared";
|
|
import { companyProjectsQuery } from "@/lib/company-projects-query";
|
|
|
|
// Sidebar star reveals with the row's own group, not the shared unnamed group.
|
|
const STAR_ROW_REVEAL =
|
|
"opacity-0 transition-opacity group-hover/starred-project:opacity-100 group-focus-within/starred-project:opacity-100";
|
|
|
|
/**
|
|
* Compact starred-project children rendered directly below the top-level
|
|
* `Projects` nav row in the streamlined sidebar. Starring/unstarring itself
|
|
* happens from browse/detail surfaces; here we only ever *remove* a star
|
|
* (plus the existing leave affordance). Archived projects are filtered out
|
|
* server-side, so a stale star never resurrects a hidden project.
|
|
*/
|
|
export function SidebarStarredProjects() {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
|
const rail = collapsed && !peeking;
|
|
const location = useLocation();
|
|
|
|
const { data: projects } = useQuery({
|
|
...companyProjectsQuery(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
const membershipsQuery = useResourceMemberships(selectedCompanyId);
|
|
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
|
|
|
|
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
|
const activeProjectRef = projectMatch?.[1] ?? null;
|
|
|
|
const starredProjects = useMemo(() => {
|
|
if (!membershipsQuery.isSuccess) return [];
|
|
const starredIds = new Set(starredResourceIds(membershipsQuery.data, "project"));
|
|
if (starredIds.size === 0) return [];
|
|
const byId = new Map((projects ?? []).map((project: Project) => [project.id, project]));
|
|
return Array.from(starredIds)
|
|
.map((id) => byId.get(id))
|
|
.filter((project): project is Project => !!project)
|
|
.sort((left, right) =>
|
|
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }),
|
|
);
|
|
}, [membershipsQuery.data, membershipsQuery.isSuccess, projects]);
|
|
|
|
const unstar = useCallback(
|
|
(project: Project) => membershipMutation.mutate({
|
|
resourceType: "project",
|
|
resourceId: project.id,
|
|
resourceName: project.name,
|
|
starred: false,
|
|
}),
|
|
[membershipMutation],
|
|
);
|
|
const leave = useCallback(
|
|
(project: Project) => membershipMutation.mutate({
|
|
resourceType: "project",
|
|
resourceId: project.id,
|
|
resourceName: project.name,
|
|
state: "left",
|
|
}),
|
|
[membershipMutation],
|
|
);
|
|
const pendingFor = useCallback(
|
|
(project: Project) =>
|
|
membershipMutation.isPending &&
|
|
membershipMutation.variables?.resourceType === "project" &&
|
|
membershipMutation.variables.resourceId === project.id,
|
|
[membershipMutation.isPending, membershipMutation.variables],
|
|
);
|
|
|
|
// Don't render anything until memberships load — no skeleton flash in the nav.
|
|
if (!membershipsQuery.isSuccess) return null;
|
|
|
|
// Empty starred groups should not add a placeholder row or extra sidebar spacing.
|
|
if (starredProjects.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-0.5" aria-label="Starred projects">
|
|
{starredProjects.map((project) => {
|
|
const routeRef = projectRouteRef(project);
|
|
const isActive = activeProjectRef === routeRef || activeProjectRef === project.id;
|
|
const pending = pendingFor(project);
|
|
const unstarPending = pending && membershipMutation.variables?.starred === false;
|
|
const leavePending = pending && membershipMutation.variables?.state === "left";
|
|
const starred = isStarred(membershipsQuery.data, "project", project.id);
|
|
|
|
const link = (
|
|
<NavLink
|
|
to={`/projects/${routeRef}/issues`}
|
|
state={SIDEBAR_SCROLL_RESET_STATE}
|
|
onClick={() => {
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
className={cn(
|
|
"flex min-w-0 flex-1 items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 pr-8 text-(length:--text-compact) font-medium transition-colors",
|
|
!rail && "pl-6",
|
|
isActive
|
|
? "bg-accent text-foreground"
|
|
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
|
)}
|
|
>
|
|
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
|
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
|
|
{!rail && project.visibility === "private" ? <Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
|
|
{!rail && project.pauseReason === "budget" ? (
|
|
<BudgetSidebarMarker title="Project paused by budget" />
|
|
) : null}
|
|
</NavLink>
|
|
);
|
|
|
|
return (
|
|
<div key={project.id} className="group/starred-project relative flex items-center">
|
|
{rail ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="min-w-0 flex-1">{link}</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right">{project.name}</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
link
|
|
)}
|
|
|
|
{!rail && !isMobile ? (
|
|
// Desktop: quiet inline unstar revealed on hover/focus.
|
|
<span className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
<StarToggle
|
|
size="row"
|
|
quiet
|
|
starred={starred}
|
|
pending={unstarPending}
|
|
resourceName={project.name}
|
|
onToggle={() => unstar(project)}
|
|
revealClassName={STAR_ROW_REVEAL}
|
|
/>
|
|
</span>
|
|
) : null}
|
|
|
|
{!rail && isMobile ? (
|
|
// Touch: explicit ⋯ menu (no hover). Star action + separated Leave.
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="absolute right-3 top-1/2 h-6 w-6 -translate-y-1/2 opacity-100"
|
|
aria-label={`Open actions for ${project.name}`}
|
|
>
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
if (pending) return;
|
|
unstar(project);
|
|
}}
|
|
disabled={pending}
|
|
>
|
|
{unstarPending ? (
|
|
<Loader2 className="size-4 motion-safe:animate-spin" />
|
|
) : (
|
|
<Star className="size-4 fill-amber-500 text-amber-500" />
|
|
)}
|
|
<span>Remove from starred</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
if (pending) return;
|
|
leave(project);
|
|
}}
|
|
disabled={pending}
|
|
>
|
|
{leavePending ? (
|
|
<Loader2 className="size-4 motion-safe:animate-spin" />
|
|
) : (
|
|
<LogOut className="size-4" />
|
|
)}
|
|
<span>Leave project</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|