diff --git a/tests/perf/collections/shared-project-list.spec.ts b/tests/perf/collections/shared-project-list.spec.ts
new file mode 100644
index 00000000000..c6552549b19
--- /dev/null
+++ b/tests/perf/collections/shared-project-list.spec.ts
@@ -0,0 +1,28 @@
+import { expect, test } from "@playwright/test";
+import { collectionFixture, type CollectionFixture } from "./fixture";
+
+let fixture: CollectionFixture;
+test.beforeAll(async ({ request }) => {
+ fixture = await collectionFixture(request);
+});
+
+// The sidebar wants active projects and the inbox wants archived ones too.
+// One includeArchived read serves both.
+test("Mine reads the company project list once", async ({ page }) => {
+ const path = `/api/companies/${fixture.company.id}/projects`;
+ const reads: URL[] = [];
+ page.on("request", item => {
+ const url = new URL(item.url());
+ if (url.pathname === path) reads.push(url);
+ });
+ const inbox = page.waitForResponse(item => new URL(item.url()).pathname === `/api/companies/${fixture.company.id}/inbox/mine` && item.ok());
+ await page.goto(`/${fixture.company.issuePrefix}/issues?view=mine`);
+ // Mine's own read has completed, so the inbox and the sidebar have both
+ // mounted their project consumers.
+ await inbox;
+ await expect.poll(() => reads.some(url => url.searchParams.get("includeArchived") === "true")).toBe(true);
+ // The live-updates socket may refresh the shared list once after it opens;
+ // that is the same read. An active-only read means the payload was fetched twice.
+ expect(reads.filter(url => url.searchParams.get("includeArchived") !== "true")).toEqual([]);
+ expect(reads.length).toBeGreaterThanOrEqual(1);
+});
diff --git a/ui/src/components/AgentProjectAccessSettings.tsx b/ui/src/components/AgentProjectAccessSettings.tsx
index 2320755c85f..e6058b77491 100644
--- a/ui/src/components/AgentProjectAccessSettings.tsx
+++ b/ui/src/components/AgentProjectAccessSettings.tsx
@@ -1,18 +1,18 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { isUuidLike } from "@paperclipai/shared";
import { agentsApi } from "../api/agents";
-import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { Link, useSearchParams } from "../lib/router";
import { AllowedProjectsEditor } from "./AllowedProjectsEditor";
import { Button } from "./ui/button";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
export function AgentProjectAccessSettings({ agent }: { agent: { id: string; companyId: string; name: string; urlKey?: string } }) {
const client = useQueryClient();
const [params] = useSearchParams();
const fromTask = params.get("fromTask");
const access = useQuery({ queryKey: queryKeys.agents.projectAccess(agent.id), queryFn: () => agentsApi.projectAccess(agent.id) });
- const projects = useQuery({ queryKey: queryKeys.projects.list(agent.companyId), queryFn: () => projectsApi.list(agent.companyId) });
+ const projects = useQuery({ ...companyProjectsQuery(agent.companyId) });
if (access.error || projects.error) return
Couldn’t load project access.
;
diff --git a/ui/src/components/CommandPalette.test.tsx b/ui/src/components/CommandPalette.test.tsx
index 0747407068c..f5c2e42d7cc 100644
--- a/ui/src/components/CommandPalette.test.tsx
+++ b/ui/src/components/CommandPalette.test.tsx
@@ -346,7 +346,7 @@ describe("CommandPalette", () => {
const { root } = renderWithQueryClient(, container, (queryClient) => {
// Seed the caches so the already-loaded data is available synchronously —
// this harness's flush model doesn't reliably propagate fresh async fetches.
- queryClient.setQueryData(queryKeys.projects.list("company-1"), projects);
+ queryClient.setQueryData(queryKeys.projects.list("company-1", { includeArchived: true }), projects);
queryClient.setQueryData(queryKeys.issues.search("company-1", "mob", undefined, 10), [
{ id: "i1", identifier: "ENG-9", title: "Fix login" },
]);
diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx
index 7f29445f678..b346ad3a9ac 100644
--- a/ui/src/components/CommandPalette.tsx
+++ b/ui/src/components/CommandPalette.tsx
@@ -9,7 +9,6 @@ import { useSidebar } from "../context/SidebarContext";
import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth";
import { agentsApi } from "../api/agents";
-import { projectsApi } from "../api/projects";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import {
@@ -43,6 +42,7 @@ import {
parseSearchQuery,
type SearchQueryParserContext,
} from "../lib/search-query-parser";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
const SEARCH_ALL_VALUE = "__paperclip-search-all__";
@@ -129,8 +129,7 @@ export function CommandPalette() {
});
const { data: allProjects = [] } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!),
- queryFn: () => projectsApi.list(selectedCompanyId!),
+ ...companyProjectsQuery(selectedCompanyId!),
enabled: !!selectedCompanyId && open,
});
const projects = useMemo(
diff --git a/ui/src/components/CoordinatorSidebar.tsx b/ui/src/components/CoordinatorSidebar.tsx
index 37766537f56..d33e596fa21 100644
--- a/ui/src/components/CoordinatorSidebar.tsx
+++ b/ui/src/components/CoordinatorSidebar.tsx
@@ -5,7 +5,6 @@ import type { Agent, Project } from "@paperclipai/shared";
import { useCoordinatorAttentionCount } from "@/hooks/useCoordinatorAttentionCount";
import { issuesApi } from "@/api/issues";
import { instanceSettingsApi } from "@/api/instanceSettings";
-import { projectsApi } from "@/api/projects";
import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation";
import { useRecentTasks } from "@/hooks/useRecentTasks";
import { usePinnedNavigation } from "@/hooks/usePinnedNavigation";
@@ -27,6 +26,7 @@ import { taskStatusIconProps } from "@/lib/task-status-presentation";
import { AgentAvatar } from "./AgentAvatar";
import { agentChatPinKey } from "./AgentChatPin";
import { PinnedNavigationList, type PinnedNavigationItem } from "./PinnedNavigationList";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
const moreDestinations = [
{ label: "Search", to: "/search", icon: Search },
@@ -122,7 +122,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
const { openNewIssue } = useDialogActions();
const location = useLocation();
const attentionCount = useCoordinatorAttentionCount(companyId);
- const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(companyId!), queryFn: () => projectsApi.list(companyId!), enabled: !!companyId });
+ const { data: projects } = useQuery({ ...companyProjectsQuery(companyId!), enabled: !!companyId });
// Only project IDs and visit times are kept in memory. Labels and task lists
// always come from the authorized API, scoped to the current person/company.
const scope = `${companyId}:${userId}`;
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx
index 82886dc1b37..62bc012474d 100644
--- a/ui/src/components/NewIssueDialog.tsx
+++ b/ui/src/components/NewIssueDialog.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { usePrimaryAgentPresentation } from "./primary-agent/PrimaryAgentPresentation";
import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls";
import { useState, useEffect, useRef, useCallback, useMemo, type CSSProperties } from "react";
@@ -10,7 +11,6 @@ import { executionWorkspacesApi } from "../api/execution-workspaces";
import { issuesApi } from "../api/issues";
import { MissingUserSecretsBanner } from "../pages/secrets/MissingUserSecretsBanner";
import { instanceSettingsApi } from "../api/instanceSettings";
-import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { accessApi } from "../api/access";
import { authApi } from "../api/auth";
@@ -517,8 +517,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean })
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(effectiveCompanyId!),
- queryFn: () => projectsApi.list(effectiveCompanyId!),
+ ...companyProjectsQuery(effectiveCompanyId!),
enabled: !!effectiveCompanyId && newIssueOpen,
});
const { data: mentionIssues } = useQuery({
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
index c2ba778a69e..a5e1eb0f383 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -1920,7 +1920,7 @@ function OnboardingWizardInner({
);
projectId = project.id;
queryClient.invalidateQueries({
- queryKey: queryKeys.projects.list(createdCompanyId)
+ queryKey: queryKeys.projects.all(createdCompanyId)
});
}
if (stillTheSameCompany(createdCompanyId)) setCreatedProjectId(projectId);
diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx
index 7e1c2a7e2d7..ce404242820 100644
--- a/ui/src/components/SidebarProjects.tsx
+++ b/ui/src/components/SidebarProjects.tsx
@@ -16,7 +16,6 @@ import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { authApi } from "../api/auth";
-import { projectsApi } from "../api/projects";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
@@ -45,6 +44,7 @@ import {
writeProjectSortMode,
} from "../lib/project-order";
import type { Project } from "@paperclipai/shared";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
type ProjectSidebarSlot = ReturnType["slots"][number];
@@ -263,8 +263,7 @@ export function SidebarProjects() {
const location = useLocation();
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!),
- queryFn: () => projectsApi.list(selectedCompanyId!),
+ ...companyProjectsQuery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
diff --git a/ui/src/components/SidebarStarredProjects.production.tsx b/ui/src/components/SidebarStarredProjects.production.tsx
index f5f26fed4f9..cdf271c1e98 100644
--- a/ui/src/components/SidebarStarredProjects.production.tsx
+++ b/ui/src/components/SidebarStarredProjects.production.tsx
@@ -4,9 +4,7 @@ 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 { projectsApi } from "../api/projects";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
-import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import {
isStarred,
@@ -27,6 +25,7 @@ import {
} 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 =
@@ -46,8 +45,7 @@ export function SidebarStarredProjects() {
const location = useLocation();
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!),
- queryFn: () => projectsApi.list(selectedCompanyId!),
+ ...companyProjectsQuery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
diff --git a/ui/src/components/SidebarStarredProjects.tsx b/ui/src/components/SidebarStarredProjects.tsx
index 97e724b7e5e..c35b4373c05 100644
--- a/ui/src/components/SidebarStarredProjects.tsx
+++ b/ui/src/components/SidebarStarredProjects.tsx
@@ -4,9 +4,7 @@ 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 { projectsApi } from "../api/projects";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
-import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import {
isStarred,
@@ -27,6 +25,7 @@ import {
} 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 =
@@ -46,8 +45,7 @@ export function SidebarStarredProjects() {
const location = useLocation();
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!),
- queryFn: () => projectsApi.list(selectedCompanyId!),
+ ...companyProjectsQuery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
diff --git a/ui/src/components/WorkspaceFileBrowser.tsx b/ui/src/components/WorkspaceFileBrowser.tsx
index 305f4d5c5ad..12d4013f601 100644
--- a/ui/src/components/WorkspaceFileBrowser.tsx
+++ b/ui/src/components/WorkspaceFileBrowser.tsx
@@ -13,7 +13,6 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn, formatDateTime } from "@/lib/utils";
import { fileResourcesApi } from "@/api/file-resources";
-import { projectsApi } from "@/api/projects";
import { ApiError } from "@/api/client";
import { describeError } from "@/api/errors";
import { queryViewKind } from "@/components/QueryView";
@@ -27,6 +26,7 @@ import type {
WorkspaceFileListMode,
WorkspaceFileSelector,
} from "@paperclipai/shared";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
type BrowserSource = "current" | "other";
@@ -539,8 +539,7 @@ export function WorkspaceFileBrowser({
const scrollContainerRef = useRef(null);
const projectsQuery = useQuery({
- queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__none__"],
- queryFn: () => projectsApi.list(companyId!),
+ ...companyProjectsQuery(companyId ?? ""),
enabled: source === "other" && !!companyId,
staleTime: 30_000,
});
diff --git a/ui/src/components/chat/AgentWorkPanels.tsx b/ui/src/components/chat/AgentWorkPanels.tsx
index 1f7b91a37c4..51472a63589 100644
--- a/ui/src/components/chat/AgentWorkPanels.tsx
+++ b/ui/src/components/chat/AgentWorkPanels.tsx
@@ -4,7 +4,6 @@ import { ArrowUpDown, File, FileText, Image, Search, Video } from "lucide-react"
import type { CompanyArtifact, Issue } from "@paperclipai/shared";
import { artifactsApi } from "@/api/artifacts";
import { issuesApi } from "@/api/issues";
-import { projectsApi } from "@/api/projects";
import { IssueFiltersPopover } from "@/components/IssueFiltersPopover";
import { StatusIcon } from "@/components/StatusIcon";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
@@ -22,6 +21,7 @@ import { queryKeys } from "@/lib/queryKeys";
import { Link } from "@/lib/router";
import { cn, formatDate, relativeTime } from "@/lib/utils";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
/**
* The agent-scoped side panels shown beside an agent chat: every task the
@@ -131,8 +131,7 @@ export function AgentTasksPanel({
});
const tasksView = useQueryView(tasksQuery);
const projectsQuery = useQuery({
- queryKey: queryKeys.projects.list(companyId),
- queryFn: () => projectsApi.list(companyId),
+ ...companyProjectsQuery(companyId),
});
const visible = useMemo(() => {
diff --git a/ui/src/components/coordinator/CoordinatorNeedsYou.tsx b/ui/src/components/coordinator/CoordinatorNeedsYou.tsx
index 447adc8b27f..ce3faa9bd37 100644
--- a/ui/src/components/coordinator/CoordinatorNeedsYou.tsx
+++ b/ui/src/components/coordinator/CoordinatorNeedsYou.tsx
@@ -7,7 +7,6 @@ import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import type { Agent, AttentionItem, DecisionHistoryItem } from "@paperclipai/shared";
import { attentionApi } from "@/api/attention";
import { agentsApi } from "@/api/agents";
-import { projectsApi } from "@/api/projects";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { usePanel } from "@/context/PanelContext";
@@ -30,6 +29,7 @@ import { parseCssTimeMs } from "@/components/task-chat/motion-tokens";
import { readIssueReviewPolicyMetadata } from "@/lib/review-policy";
import { CoordinatorDecisionReceipt as Receipt } from "./CoordinatorDecisionReceipt";
import { CoordinatorTaskMotion } from "./CoordinatorTaskCommitment";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
type Group = "none" | "task" | "agent" | "project";
@@ -41,7 +41,7 @@ function HistoryPanel({ companyId, agents, onClose, mobile = false }: { companyI
const [projectId, setProjectId] = useState("");
const [groupBy, setGroupBy] = useState("none");
const [sort, setSort] = useState<"newest" | "oldest">("newest");
- const projects = useQuery({ queryKey: queryKeys.projects.list(companyId), queryFn: () => projectsApi.list(companyId) });
+ const projects = useQuery({ ...companyProjectsQuery(companyId) });
const history = useInfiniteQuery({
queryKey: [...queryKeys.attention(companyId), "history", { search, outcome, agentId, projectId, sort }],
queryFn: ({ pageParam }) => attentionApi.history(companyId, { search, outcome, agentId, projectId, sort, cursor: pageParam, limit: 40 }),
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 2ca2e7b03de..705fedb32e1 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { isLockedIssueStub } from "@/components/LockedIssueChip";
import { IssuePullRequestLinks } from "../IssuePullRequestLinks";
import { useIssueWorkProducts } from "../../hooks/useIssueWorkProducts";
@@ -32,7 +33,6 @@ import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
import { selectArtifactAttachments } from "@/lib/issue-artifacts";
-import { projectsApi } from "../../api/projects";
import { useCompany } from "../../context/CompanyContext";
import { useSidebar } from "../../context/SidebarContext";
import { queryKeys } from "../../lib/queryKeys";
@@ -452,8 +452,7 @@ export function IssueProperties({
enabled: !!companyId,
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(companyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(companyId!, { includeArchived: true }),
+ ...companyProjectsQuery(companyId!, { includeArchived: true }),
enabled: !!companyId,
});
const activeProjects = useMemo(
diff --git a/ui/src/hooks/useStandardMarkdownMentionOptions.ts b/ui/src/hooks/useStandardMarkdownMentionOptions.ts
index b3c7ef462a2..ee1505d31af 100644
--- a/ui/src/hooks/useStandardMarkdownMentionOptions.ts
+++ b/ui/src/hooks/useStandardMarkdownMentionOptions.ts
@@ -2,10 +2,10 @@ import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { agentsApi } from "../api/agents";
import { accessApi } from "../api/access";
-import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { buildMarkdownMentionOptions } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
type MarkdownMentionInputs = Parameters[0];
@@ -25,8 +25,7 @@ export function useStandardMarkdownMentionOptions(args: StandardMarkdownMentionO
enabled: enabled && args.agents === undefined,
});
const projectsQuery = useQuery({
- queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "standard-mentions", "none"],
- queryFn: () => projectsApi.list(companyId!),
+ ...companyProjectsQuery(companyId ?? ""),
enabled: enabled && args.projects === undefined,
});
const usersQuery = useQuery({
diff --git a/ui/src/lib/company-projects-query.test.tsx b/ui/src/lib/company-projects-query.test.tsx
new file mode 100644
index 00000000000..517c7de1c10
--- /dev/null
+++ b/ui/src/lib/company-projects-query.test.tsx
@@ -0,0 +1,75 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({ list: vi.fn() }));
+vi.mock("@/api/projects", () => ({ projectsApi: { list: mocks.list } }));
+
+import { companyProjectsQuery } from "./company-projects-query";
+import { queryKeys } from "./queryKeys";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+const active = { id: "p-active", name: "Active", archivedAt: null };
+const archived = { id: "p-archived", name: "Archived", archivedAt: "2026-11-01T00:00:00.000Z" };
+
+describe("companyProjectsQuery", () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+ let queryClient: QueryClient;
+
+ beforeEach(() => {
+ mocks.list.mockReset().mockResolvedValue([active, archived]);
+ queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ queryClient.clear();
+ });
+
+ it("serves active-only and archive-inclusive consumers from one request", async () => {
+ const seen: Record = {};
+ function Sidebar() {
+ const { data } = useQuery(companyProjectsQuery("company-1"));
+ seen.sidebar = data?.map((project) => project.id);
+ return null;
+ }
+ function Inbox() {
+ const { data } = useQuery(companyProjectsQuery("company-1", { includeArchived: true }));
+ seen.inbox = data?.map((project) => project.id);
+ return null;
+ }
+
+ act(() => {
+ root.render(
+
+
+
+ ,
+ );
+ });
+ await vi.waitFor(() => {
+ expect(seen.sidebar).toEqual(["p-active"]);
+ expect(seen.inbox).toEqual(["p-active", "p-archived"]);
+ });
+ expect(mocks.list).toHaveBeenCalledTimes(1);
+ expect(mocks.list).toHaveBeenCalledWith("company-1", { includeArchived: true });
+
+ // Project mutations invalidate the company prefix; both views refresh from one read.
+ mocks.list.mockResolvedValue([active, { ...archived, archivedAt: null }]);
+ await act(async () => {
+ await queryClient.invalidateQueries({ queryKey: queryKeys.projects.all("company-1") });
+ });
+ await vi.waitFor(() => expect(seen.sidebar).toEqual(["p-active", "p-archived"]));
+ expect(mocks.list).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/ui/src/lib/company-projects-query.ts b/ui/src/lib/company-projects-query.ts
new file mode 100644
index 00000000000..10065e77310
--- /dev/null
+++ b/ui/src/lib/company-projects-query.ts
@@ -0,0 +1,26 @@
+import { queryOptions } from "@tanstack/react-query";
+import type { Project } from "@paperclipai/shared";
+import { projectsApi } from "@/api/projects";
+import { queryKeys } from "@/lib/queryKeys";
+
+/** Active projects are exactly those the server would return without `includeArchived`. */
+export function selectActiveProjects(projects: Project[]): Project[] {
+ return projects.filter((project) => !project.archivedAt);
+}
+
+/**
+ * The company project list. Every consumer shares the single
+ * `includeArchived=true` read; active-only consumers filter it with `select`.
+ * Two lists under separate keys used to fetch the same large payload twice on
+ * one page (for example the sidebar and the Mine inbox).
+ * `queryKeys.projects.all(companyId)` invalidation still reaches it.
+ */
+export function companyProjectsQuery(companyId: string, opts: { includeArchived?: boolean } = {}) {
+ const base = {
+ queryKey: queryKeys.projects.list(companyId, { includeArchived: true }),
+ queryFn: () => projectsApi.list(companyId, { includeArchived: true }),
+ };
+ return opts.includeArchived
+ ? queryOptions(base)
+ : queryOptions({ ...base, select: selectActiveProjects });
+}
diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx
index c6fc9a77353..b4531da983c 100644
--- a/ui/src/pages/AgentDetail.production.tsx
+++ b/ui/src/pages/AgentDetail.production.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { describeError } from "../api/errors";
import { useSelectedHeartbeatRun } from "../hooks/useSelectedHeartbeatRun";
import { AgentCharacter } from "../components/AgentCharacter";
@@ -24,7 +25,6 @@ import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
import { activityApi } from "../api/activity";
import { accessApi } from "../api/access";
import { issuesApi } from "../api/issues";
-import { projectsApi } from "../api/projects";
import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
@@ -1646,8 +1646,7 @@ export function ConfigurationTab({
const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review";
const { data: boundaryProjects, isLoading: boundaryProjectsLoading } = useQuery({
- queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__low-trust-disabled"],
- queryFn: () => projectsApi.list(companyId!),
+ ...companyProjectsQuery(companyId ?? ""),
enabled: Boolean(companyId && lowTrustSelected) && content === "configuration",
});
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index 31a2e2966c1..76a25681c1d 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { describeError } from "../api/errors";
import { useSelectedHeartbeatRun } from "../hooks/useSelectedHeartbeatRun";
import { grokBotApi } from "@/api/grokBot";
@@ -30,7 +31,6 @@ import { ApiError } from "../api/client";
import { activityApi } from "../api/activity";
import { accessApi } from "../api/access";
import { issuesApi } from "../api/issues";
-import { projectsApi } from "../api/projects";
import { usePanel } from "../context/PanelContext";
import { useSidebar } from "../context/SidebarContext";
import { useCompany } from "../context/CompanyContext";
@@ -1659,8 +1659,7 @@ export function ConfigurationTab({
const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review";
const { data: boundaryProjects, isLoading: boundaryProjectsLoading } = useQuery({
- queryKey: companyId ? queryKeys.projects.list(companyId) : ["projects", "__low-trust-disabled"],
- queryFn: () => projectsApi.list(companyId!),
+ ...companyProjectsQuery(companyId ?? ""),
enabled: Boolean(companyId && lowTrustSelected) && content === "permissions",
});
diff --git a/ui/src/pages/Cases.tsx b/ui/src/pages/Cases.tsx
index ebe45b99c4c..57b024d2c25 100644
--- a/ui/src/pages/Cases.tsx
+++ b/ui/src/pages/Cases.tsx
@@ -1,9 +1,9 @@
+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 { projectsApi } from "@/api/projects";
import { CaseListRow } from "@/components/cases/CaseListRow";
import { CaseListControls } from "@/components/cases/CaseListControls";
import { CollectionToolbar } from "@/components/CollectionToolbar";
@@ -86,8 +86,7 @@ export function Cases() {
});
const typesQueryView = useQueryView(typesQuery);
const projectsQuery = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId ?? "", { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId ?? "", { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const projectsQueryView = useQueryView(projectsQuery);
diff --git a/ui/src/pages/CompanyExport.test.tsx b/ui/src/pages/CompanyExport.test.tsx
index 50e520bfb37..f9b74e94678 100644
--- a/ui/src/pages/CompanyExport.test.tsx
+++ b/ui/src/pages/CompanyExport.test.tsx
@@ -244,7 +244,7 @@ describe("CompanyExport", () => {
expect(container.textContent).not.toContain("Prepare export preview");
expect(mockAuthApi.getSession).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.list).toHaveBeenCalledWith("company-1");
- expect(mockProjectsApi.list).toHaveBeenCalledWith("company-1");
+ expect(mockProjectsApi.list).toHaveBeenCalledWith("company-1", { includeArchived: true });
expect(mockCompaniesApi.exportPreview).toHaveBeenCalledTimes(1);
expect(mockCompaniesApi.exportPreview.mock.calls[0]?.[1]).toMatchObject({
include: { company: true, agents: true, projects: true, issues: false, skills: true },
diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx
index 9e353a91b98..d0d63bcfb66 100644
--- a/ui/src/pages/CompanyExport.tsx
+++ b/ui/src/pages/CompanyExport.tsx
@@ -15,7 +15,6 @@ import { useToastActions } from "../context/ToastContext";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
-import { projectsApi } from "../api/projects";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
@@ -59,6 +58,7 @@ import {
FileTree,
} from "../components/FileTree";
import { describeError } from "@/api/errors";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
/**
* Extract the set of agent/project/task slugs that are "checked" based on
@@ -643,8 +643,7 @@ export function CompanyExport() {
enabled: !!selectedCompanyId,
});
const { data: projects = [] } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!),
- queryFn: () => projectsApi.list(selectedCompanyId!),
+ ...companyProjectsQuery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: fidelityReport } = useQuery({
diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx
index 151668e54d5..6d66fdaf8e3 100644
--- a/ui/src/pages/CoordinatorTasks.tsx
+++ b/ui/src/pages/CoordinatorTasks.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { readTaskListSortParams, taskListOrderToIssueSort, type TaskListSort } from "@/lib/task-list";
import { useDeferredValue, useEffect, useRef, useState, type ReactNode } from "react";
import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode";
@@ -8,7 +9,6 @@ import { agentsApi } from "@/api/agents";
import { attentionApi } from "@/api/attention";
import { heartbeatsApi } from "@/api/heartbeats";
import { issuesApi } from "@/api/issues";
-import { projectsApi } from "@/api/projects";
import { EmptyState } from "@/components/EmptyState";
import { KanbanBoard } from "@/components/KanbanBoard";
import { TaskList, type TaskListItem } from "@/components/TaskList";
@@ -150,7 +150,7 @@ export function CoordinatorTasks({ viewControl }: { viewControl?: ReactNode } =
enabled: !!companyId,
})) });
const agents = useQuery({ queryKey: queryKeys.agents.list(companyId!), queryFn: () => agentsApi.list(companyId!), enabled: !!companyId });
- const projects = useQuery({ queryKey: queryKeys.projects.list(companyId!, { includeArchived: true }), queryFn: () => projectsApi.list(companyId!, { includeArchived: true }), enabled: !!companyId });
+ const projects = useQuery({ ...companyProjectsQuery(companyId!, { includeArchived: true }), enabled: !!companyId });
const directory = useQuery({ queryKey: queryKeys.access.companyUserDirectory(companyId!), queryFn: () => accessApi.listUserDirectory(companyId!), enabled: !!companyId });
const labels = useQuery({ queryKey: queryKeys.issues.labels(companyId!), queryFn: () => issuesApi.listLabels(companyId!), enabled: !!companyId });
const sharedLiveRuns = useSharedPollingQuery({ companyId, resourceKey: "live-runs", queryKey: queryKeys.liveRuns(companyId!), enabled: !!companyId, refetchInterval: false, leaderOnly: true });
diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx
index 23130b0198e..0d9a71537d9 100644
--- a/ui/src/pages/Dashboard.tsx
+++ b/ui/src/pages/Dashboard.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { AgentIdentity } from "../components/AgentIdentity";
import { TaskSummaryList } from "../components/TaskSummaryList";
import { useDesignSandbox } from "../context/DesignSandboxContext";
@@ -13,7 +14,6 @@ import { activityApi } from "../api/activity";
import { accessApi } from "../api/access";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
-import { projectsApi } from "../api/projects";
import { buildCompanyUserProfileMap } from "../lib/company-members";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
@@ -202,8 +202,7 @@ export function Dashboard() {
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
diff --git a/ui/src/pages/DesignSandboxTasks.tsx b/ui/src/pages/DesignSandboxTasks.tsx
index 3c56c479cf4..eb7ba9c874e 100644
--- a/ui/src/pages/DesignSandboxTasks.tsx
+++ b/ui/src/pages/DesignSandboxTasks.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { useEffect } from "react";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ListChecks, SearchX } from "lucide-react";
@@ -5,7 +6,6 @@ import { accessApi } from "@/api/access";
import { agentsApi } from "@/api/agents";
import { useAccountIdentity } from "@/api/companies-query";
import { issuesApi } from "@/api/issues";
-import { projectsApi } from "@/api/projects";
import { EmptyState } from "@/components/EmptyState";
import { KanbanBoard } from "@/components/KanbanBoard";
import { TaskList, type TaskListItem } from "@/components/TaskList";
@@ -45,7 +45,7 @@ export function DesignSandboxTasks() {
enabled: !!companyId,
});
const agents = useQuery({ queryKey: queryKeys.agents.list(companyId!), queryFn: () => agentsApi.list(companyId!), enabled: !!companyId });
- const projects = useQuery({ queryKey: queryKeys.projects.list(companyId!, { includeArchived: true }), queryFn: () => projectsApi.list(companyId!, { includeArchived: true }), enabled: !!companyId });
+ const projects = useQuery({ ...companyProjectsQuery(companyId!, { includeArchived: true }), enabled: !!companyId });
const directory = useQuery({ queryKey: queryKeys.access.companyUserDirectory(companyId!), queryFn: () => accessApi.listUserDirectory(companyId!), enabled: !!companyId });
const labels = useQuery({ queryKey: queryKeys.issues.labels(companyId!), queryFn: () => issuesApi.listLabels(companyId!), enabled: !!companyId });
// The ordinary organization list does not include viewer read context.
diff --git a/ui/src/pages/GoalDetail.tsx b/ui/src/pages/GoalDetail.tsx
index 5bcf7a9bca6..edfce1697e9 100644
--- a/ui/src/pages/GoalDetail.tsx
+++ b/ui/src/pages/GoalDetail.tsx
@@ -1,8 +1,8 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { useEffect } from "react";
import { useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { goalsApi } from "../api/goals";
-import { projectsApi } from "../api/projects";
import { assetsApi } from "../api/assets";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
@@ -71,8 +71,7 @@ export function GoalDetail() {
});
const { data: allProjects } = useQuery({
- queryKey: queryKeys.projects.list(resolvedCompanyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(resolvedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(resolvedCompanyId!, { includeArchived: true }),
enabled: !!resolvedCompanyId
});
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index e86eaf9eaac..578791fe7e3 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { NativeControllerContainmentRecovery } from "../components/IssueRecoveryActionCard";
import { useIssueIdBatches } from "@/hooks/useIssueIdBatches";
import { TaskChatLoadingShell } from "@/components/task-chat/TaskChatLoadingShell";
@@ -103,7 +104,6 @@ import {
} from "../lib/recovery-reconcile";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
-import { projectsApi } from "../api/projects";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
@@ -3716,8 +3716,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
});
const { data: projects = EMPTY_PROJECTS } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!),
- queryFn: () => projectsApi.list(selectedCompanyId!),
+ ...companyProjectsQuery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx
index 0ebf1ceff1f..10a24fdb84a 100644
--- a/ui/src/pages/Issues.tsx
+++ b/ui/src/pages/Issues.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks";
import { useCoordinatorWorkspaceEnabled } from "../hooks/useCoordinatorWorkspaceEnabled";
@@ -10,7 +11,6 @@ import { useLocation, useNavigate, useSearchParams } from "@/lib/router";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
-import { projectsApi } from "../api/projects";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -236,8 +236,7 @@ function OrganizationIssues({
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
diff --git a/ui/src/pages/LegacyInbox.tsx b/ui/src/pages/LegacyInbox.tsx
index 6bfde7ff373..8bc2a5a2e7a 100644
--- a/ui/src/pages/LegacyInbox.tsx
+++ b/ui/src/pages/LegacyInbox.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { describeError } from "../api/errors";
import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -16,7 +17,6 @@ import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
-import { projectsApi } from "../api/projects";
import {
BLOCKED_GROUP_OPTIONS,
BLOCKED_SORT_OPTIONS,
@@ -754,8 +754,7 @@ export function Inbox() {
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const { data: labels } = useQuery({
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx
index dbcc211f8aa..f22f9e207b9 100644
--- a/ui/src/pages/ProjectDetail.tsx
+++ b/ui/src/pages/ProjectDetail.tsx
@@ -48,6 +48,7 @@ import {
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
/* ── Top-level tab types ── */
@@ -216,8 +217,7 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(companyId),
- queryFn: () => projectsApi.list(companyId),
+ ...companyProjectsQuery(companyId),
enabled: !!companyId,
});
@@ -270,8 +270,7 @@ function ProjectPluginOperationsList({
enabled: !!companyId,
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(companyId),
- queryFn: () => projectsApi.list(companyId),
+ ...companyProjectsQuery(companyId),
enabled: !!companyId,
});
const liveRunsQueryKey = queryKeys.liveRuns(companyId);
diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx
index 6f83abebb8a..3714cdddf9b 100644
--- a/ui/src/pages/Projects.tsx
+++ b/ui/src/pages/Projects.tsx
@@ -1,11 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
-import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
-import { queryKeys } from "../lib/queryKeys";
import { EntityRow } from "../components/EntityRow";
import { ProjectTile } from "../components/ProjectTile";
import { StatusBadge } from "../components/StatusBadge";
@@ -26,6 +24,7 @@ import { ProjectsToolbar, type ProjectSortField, type ProjectSortDir } from "@/c
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" });
@@ -81,8 +80,7 @@ export function Projects() {
}, [setBreadcrumbs]);
const projectsQuery = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!),
- queryFn: () => projectsApi.list(selectedCompanyId!),
+ ...companyProjectsQuery(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: allProjects, isLoading } = projectsQuery;
diff --git a/ui/src/pages/RoutineDetail.production.tsx b/ui/src/pages/RoutineDetail.production.tsx
index b1228a78d1a..a16ca2a9060 100644
--- a/ui/src/pages/RoutineDetail.production.tsx
+++ b/ui/src/pages/RoutineDetail.production.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Navigate, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -13,7 +14,6 @@ import { secretsApi } from "../api/secrets";
import { type RoutineHistoryDirtyFieldDescriptor } from "../components/RoutineHistoryTab";
import { heartbeatsApi } from "../api/heartbeats";
import { agentsApi } from "../api/agents";
-import { projectsApi } from "../api/projects";
import { accessApi } from "../api/access";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -241,8 +241,7 @@ export function RoutineDetail() {
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx
index bf1e3aa1f2f..f4a81ed7d7b 100644
--- a/ui/src/pages/RoutineDetail.tsx
+++ b/ui/src/pages/RoutineDetail.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Navigate, useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -13,7 +14,6 @@ import { secretsApi } from "../api/secrets";
import { type RoutineHistoryDirtyFieldDescriptor } from "../components/RoutineHistoryTab";
import { heartbeatsApi } from "../api/heartbeats";
import { agentsApi } from "../api/agents";
-import { projectsApi } from "../api/projects";
import { accessApi } from "../api/access";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -223,8 +223,7 @@ export function RoutineDetail() {
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
diff --git a/ui/src/pages/Routines.production.tsx b/ui/src/pages/Routines.production.tsx
index 31936d21e5a..71e96ae218d 100644
--- a/ui/src/pages/Routines.production.tsx
+++ b/ui/src/pages/Routines.production.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { AgentAvatar } from "@/components/AgentAvatar";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -6,7 +7,6 @@ import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } f
import { routinesApi } from "../api/routines";
import { foldersApi } from "../api/folders";
import { agentsApi } from "../api/agents";
-import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { heartbeatsApi } from "../api/heartbeats";
import { accessApi } from "../api/access";
@@ -395,8 +395,7 @@ export function Routines() {
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx
index 00154a1a5eb..296558ca690 100644
--- a/ui/src/pages/Routines.tsx
+++ b/ui/src/pages/Routines.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { AgentAvatar } from "@/components/AgentAvatar";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -6,7 +7,6 @@ import { ChevronDown, ChevronRight, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { foldersApi } from "../api/folders";
import { agentsApi } from "../api/agents";
-import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { heartbeatsApi } from "../api/heartbeats";
import { accessApi } from "../api/access";
@@ -391,8 +391,7 @@ export function Routines() {
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
diff --git a/ui/src/pages/Search.tsx b/ui/src/pages/Search.tsx
index 844eacab19d..fce7655830a 100644
--- a/ui/src/pages/Search.tsx
+++ b/ui/src/pages/Search.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search as SearchIcon, AlertTriangle, FileQuestion, Plus, X } from "lucide-react";
@@ -25,7 +26,6 @@ import { useQueryView } from "../components/QueryView";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { issuesApi } from "../api/issues";
-import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { loadRecentSearches, pushRecentSearch } from "../lib/recent-searches";
import { PageTabBar, type PageTabItem } from "../components/PageTabBar";
@@ -187,8 +187,7 @@ export function Search() {
});
const { data: projects = [] } = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId!, { includeArchived: true }),
enabled: !!selectedCompanyId,
});
diff --git a/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx b/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx
index 7e3ccd4ad7b..51b5982cdb2 100644
--- a/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx
+++ b/ui/src/pages/apps/chat/ChatExecutionDefaults.tsx
@@ -1,11 +1,10 @@
import { useEffect, useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { ChatExecutionDefaults as Defaults, TaskWorkspaceSelection } from "@paperclipai/shared";
-import { projectsApi } from "@/api/projects";
import { executionWorkspacesApi } from "@/api/execution-workspaces";
import { Button } from "@/components/ui/button";
import { NativeSelect } from "@/components/ui/select";
-import { queryKeys } from "@/lib/queryKeys";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
function selectionKey(value: TaskWorkspaceSelection | null | undefined): string {
if (value === undefined) return "inherit";
@@ -26,7 +25,7 @@ export function ChatExecutionDefaults({ companyId, value, resource = false, onSa
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const [savedFrom, setSavedFrom] = useState(null);
- const projects = useQuery({ queryKey: queryKeys.projects.list(companyId), queryFn: () => projectsApi.list(companyId) });
+ const projects = useQuery({ ...companyProjectsQuery(companyId) });
const workspaces = useQuery({
queryKey: ["channel-workspace-choices", companyId],
queryFn: () => executionWorkspacesApi.listSummaries(companyId, { selectableForTask: true }),
diff --git a/ui/src/pages/apps/chat/EmailEndpointSetup.tsx b/ui/src/pages/apps/chat/EmailEndpointSetup.tsx
index a6ee9a0b9c6..b4aeca4eaf6 100644
--- a/ui/src/pages/apps/chat/EmailEndpointSetup.tsx
+++ b/ui/src/pages/apps/chat/EmailEndpointSetup.tsx
@@ -20,7 +20,6 @@ import { useCompany } from "@/context/CompanyContext";
import { useNavigate, useSearchParams, Link } from "@/lib/router";
import { agentsApi } from "@/api/agents";
import { issuesApi } from "@/api/issues";
-import { projectsApi } from "@/api/projects";
import { toolsApi } from "@/api/tools";
import { emailApi } from "@/api/email";
import { Button } from "@/components/ui/button";
@@ -51,6 +50,7 @@ import type {
AgentPermissions,
EmailEndpointSummary,
} from "@paperclipai/shared";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
const selectClass =
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
@@ -134,8 +134,7 @@ function EmailEndpointSetupForm({ companyId }: { companyId: string }) {
const agents = useQuery({ queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId), enabled: !!companyId });
const agentsView = useQueryView(agents);
- const projects = useQuery({ queryKey: queryKeys.projects.list(companyId),
- queryFn: () => projectsApi.list(companyId), enabled: !!companyId && trustOpen });
+ const projects = useQuery({ ...companyProjectsQuery(companyId), enabled: !!companyId && trustOpen });
const projectsView = useQueryView(projects);
const boundaryIssues = useQuery({ queryKey: ["email-boundary-issues", companyId],
queryFn: () => issuesApi.list(companyId), enabled: !!companyId && trustOpen });
diff --git a/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx b/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx
index f3a4a1c911c..592bf3314f0 100644
--- a/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx
+++ b/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx
@@ -324,7 +324,7 @@ describe("GitHub bot management", () => {
const editor = container.querySelector('[role="region"] form')!;
const [project, workspace] = [...editor.querySelectorAll("select")];
await vi.waitFor(() => expect(workspace.disabled).toBe(false));
- expect(mocks.projects).toHaveBeenCalledWith("company");
+ expect(mocks.projects).toHaveBeenCalledWith("company", { includeArchived: true });
expect(mocks.workspaces).toHaveBeenCalledWith("company", { selectableForTask: true });
expect(project.value).toBe("inherit");
expect(workspace.value).toBe("inherit");
diff --git a/ui/src/pages/apps/gateways/GatewayDetail.tsx b/ui/src/pages/apps/gateways/GatewayDetail.tsx
index 67291e4abd6..369fef76603 100644
--- a/ui/src/pages/apps/gateways/GatewayDetail.tsx
+++ b/ui/src/pages/apps/gateways/GatewayDetail.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Pencil, Send } from "lucide-react";
@@ -9,7 +10,6 @@ import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
-import { projectsApi } from "@/api/projects";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -66,8 +66,7 @@ export function GatewayDetail() {
enabled: !!selectedCompanyId,
});
const projectsQuery = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId ?? "__none__", { includeArchived: true }),
- queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
+ ...companyProjectsQuery(selectedCompanyId ?? "__none__", { includeArchived: true }),
enabled: !!selectedCompanyId,
});
const gatewaysView = useQueryView(gatewaysQuery);
diff --git a/ui/src/pages/apps/gateways/GatewaysList.tsx b/ui/src/pages/apps/gateways/GatewaysList.tsx
index d28728921df..834c1effef1 100644
--- a/ui/src/pages/apps/gateways/GatewaysList.tsx
+++ b/ui/src/pages/apps/gateways/GatewaysList.tsx
@@ -9,7 +9,6 @@ import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
-import { projectsApi } from "@/api/projects";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -29,6 +28,7 @@ import {
latestTokenActivity,
} from "./gateway-helpers";
import { describeError } from "@/api/errors";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
export function GatewaysList() {
const navigate = useNavigate();
@@ -73,8 +73,7 @@ export function GatewaysList() {
enabled: !!selectedCompanyId,
});
const projectsQuery = useQuery({
- queryKey: queryKeys.projects.list(selectedCompanyId ?? "__none__"),
- queryFn: () => projectsApi.list(selectedCompanyId!),
+ ...companyProjectsQuery(selectedCompanyId ?? "__none__"),
enabled: !!selectedCompanyId,
});
const gatewaysView = useQueryView(gatewaysQuery);
diff --git a/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx b/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx
index c77b9e31f5b..3d445ae6ca7 100644
--- a/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx
+++ b/ui/src/pages/skills/ImportSkillsFromProjectDialog.tsx
@@ -31,7 +31,6 @@ import { normalizeAgentUrlKey } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { ApiError } from "../../api/client";
import { companySkillsApi } from "../../api/companySkills";
-import { projectsApi } from "../../api/projects";
import { useToastActions } from "../../context/ToastContext";
import { queryKeys } from "../../lib/queryKeys";
import { skillStudioRoute } from "../../lib/company-skill-routes";
@@ -49,6 +48,7 @@ import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { EmptyState } from "../../components/EmptyState";
import { cn } from "../../lib/utils";
import { describeError } from "@/api/errors";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
type Step = "pick" | "scanning" | "select" | "result";
export type SkillSelection = { workspaceId: string; path: string; slug?: string };
@@ -295,8 +295,7 @@ export function ImportSkillsFromProjectDialog({
const scanTokenRef = useRef(0);
const projectsQuery = useQuery({
- queryKey: queryKeys.projects.list(companyId),
- queryFn: () => projectsApi.list(companyId),
+ ...companyProjectsQuery(companyId),
enabled: open,
});
const projectsView = useQueryView(projectsQuery);
diff --git a/ui/src/pages/tools/GatewaysTab.tsx b/ui/src/pages/tools/GatewaysTab.tsx
index 2715426cb96..8478304b773 100644
--- a/ui/src/pages/tools/GatewaysTab.tsx
+++ b/ui/src/pages/tools/GatewaysTab.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { type FormEvent, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
@@ -9,7 +10,6 @@ import type {
} from "@paperclipai/shared";
import { Check, ChevronDown, Copy, KeyRound, Link as LinkIcon, Plus, RotateCcw, X } from "lucide-react";
import { agentsApi } from "@/api/agents";
-import { projectsApi } from "@/api/projects";
import { toolsApi } from "@/api/tools";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { Button } from "@/components/ui/button";
@@ -141,8 +141,7 @@ export function GatewaysTab({ companyId }: { companyId: string }) {
queryFn: () => agentsApi.list(companyId),
});
const projectsQuery = useQuery({
- queryKey: queryKeys.projects.list(companyId, { includeArchived: true }),
- queryFn: () => projectsApi.list(companyId, { includeArchived: true }),
+ ...companyProjectsQuery(companyId, { includeArchived: true }),
});
const gatewaysView = useQueryView(gatewaysQuery);
diff --git a/ui/src/pages/tools/ProfilesTab.tsx b/ui/src/pages/tools/ProfilesTab.tsx
index ded0ba0b9eb..f4bc4d9433b 100644
--- a/ui/src/pages/tools/ProfilesTab.tsx
+++ b/ui/src/pages/tools/ProfilesTab.tsx
@@ -1,3 +1,4 @@
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { useMemo, useState } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Layers, Plus, Pencil, Trash2, Link2, ShieldCheck } from "lucide-react";
@@ -14,7 +15,6 @@ import type {
ToolRiskLevel,
} from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
-import { projectsApi } from "@/api/projects";
import { routinesApi } from "@/api/routines";
import {
toolsApi,
@@ -313,8 +313,7 @@ function useLookupData(companyId: string) {
queryFn: () => agentsApi.list(companyId),
});
const projects = useQuery({
- queryKey: queryKeys.projects.list(companyId, { includeArchived: true }),
- queryFn: () => projectsApi.list(companyId, { includeArchived: true }),
+ ...companyProjectsQuery(companyId, { includeArchived: true }),
});
const routines = useQuery({
queryKey: queryKeys.routines.list(companyId),
diff --git a/ui/src/pages/tools/profiles/useProfilesData.ts b/ui/src/pages/tools/profiles/useProfilesData.ts
index 1ac2a03d68d..6b201519370 100644
--- a/ui/src/pages/tools/profiles/useProfilesData.ts
+++ b/ui/src/pages/tools/profiles/useProfilesData.ts
@@ -1,11 +1,11 @@
import { useMemo } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { agentsApi } from "@/api/agents";
-import { projectsApi } from "@/api/projects";
import { routinesApi } from "@/api/routines";
import { toolsApi } from "@/api/tools";
import { queryKeys } from "@/lib/queryKeys";
import { groupCatalogByApp } from "./profile-model";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
/**
* Shared data layer for the access-profiles index and wizard. Assembles the
@@ -30,8 +30,7 @@ export function useProfilesData(companyId: string) {
queryFn: () => agentsApi.list(companyId),
});
const projects = useQuery({
- queryKey: queryKeys.projects.list(companyId),
- queryFn: () => projectsApi.list(companyId),
+ ...companyProjectsQuery(companyId),
});
const routines = useQuery({
queryKey: queryKeys.routines.list(companyId),
diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts
index e94a3e880c4..9269db4c9db 100644
--- a/ui/src/plugins/bridge-init.ts
+++ b/ui/src/plugins/bridge-init.ts
@@ -21,6 +21,7 @@ import {
usePluginStream,
usePluginToast,
} from "./bridge.js";
+import { companyProjectsQuery } from "@/lib/company-projects-query";
import { Component, createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
import * as ReactJsxRuntimeModule from "react/jsx-runtime";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -39,7 +40,6 @@ import { agentsApi } from "@/api/agents";
import { authApi } from "@/api/auth";
import { heartbeatsApi } from "@/api/heartbeats";
import { issuesApi } from "@/api/issues";
-import { projectsApi } from "@/api/projects";
import {
buildCompanyUserInlineOptions,
} from "@/lib/company-members";
@@ -277,8 +277,7 @@ function PluginSdkIssuesList({
enabled: !!companyId,
});
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(companyId ?? "__no-company__", { includeArchived: true }),
- queryFn: () => projectsApi.list(companyId!, { includeArchived: true }),
+ ...companyProjectsQuery(companyId ?? "__no-company__", { includeArchived: true }),
enabled: !!companyId,
});
const liveRunsQueryKey = queryKeys.liveRuns(companyId ?? "__no-company__");
@@ -473,8 +472,7 @@ function PluginSdkProjectPicker({
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { data: projects } = useQuery({
- queryKey: queryKeys.projects.list(resolvedCompanyId ?? "__no-company__", { includeArchived }),
- queryFn: () => projectsApi.list(resolvedCompanyId!, { includeArchived }),
+ ...companyProjectsQuery(resolvedCompanyId ?? "__no-company__", { includeArchived }),
enabled: !!resolvedCompanyId,
});
const visibleProjects = useMemo(