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>
960 lines
38 KiB
TypeScript
960 lines
38 KiB
TypeScript
import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks";
|
||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { PROJECT_COLORS, PROJECT_ICON_NAMES, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||
import { budgetsApi } from "../api/budgets";
|
||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||
import { projectsApi } from "../api/projects";
|
||
import { issuesApi } from "../api/issues";
|
||
import { agentsApi } from "../api/agents";
|
||
import { heartbeatsApi } from "../api/heartbeats";
|
||
import { assetsApi } from "../api/assets";
|
||
import { usePanel } from "../context/PanelContext";
|
||
import { useCompany } from "../context/CompanyContext";
|
||
import { useToastActions } from "../context/ToastContext";
|
||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||
import { queryKeys } from "../lib/queryKeys";
|
||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||
import { InlineEditor } from "../components/InlineEditor";
|
||
import { StatusBadge } from "../components/StatusBadge";
|
||
import { ProjectTile } from "../components/ProjectTile";
|
||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||
import { IssuesList } from "../components/IssuesList";
|
||
import { PageSkeleton } from "../components/PageSkeleton";
|
||
import { QueryErrorState, useQueryView } from "../components/QueryView";
|
||
import { PageTabBar } from "../components/PageTabBar";
|
||
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
|
||
import { SummarySlotCard } from "../components/SummarySlotCard";
|
||
import { MembershipAction } from "../components/MembershipAction";
|
||
import { StarToggle } from "../components/StarToggle";
|
||
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||
import { projectRouteRef } from "../lib/utils";
|
||
import { PROJECT_ICONS } from "../lib/project-icons";
|
||
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import { cn } from "@/lib/utils";
|
||
import { Tabs } from "@/components/ui/tabs";
|
||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||
import {
|
||
isStarred,
|
||
resourceMembershipState,
|
||
useResourceMembershipMutation,
|
||
useResourceMemberships,
|
||
} from "../hooks/useResourceMemberships";
|
||
import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation";
|
||
import { companyProjectsQuery } from "@/lib/company-projects-query";
|
||
|
||
/* ── Top-level tab types ── */
|
||
|
||
type ProjectBaseTab = "list" | "plugin-operations" | "workspaces" | "configuration" | "budget";
|
||
type ProjectPluginTab = `plugin:${string}`;
|
||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||
|
||
function isProjectPluginTab(value: string | null): value is ProjectPluginTab {
|
||
return typeof value === "string" && value.startsWith("plugin:");
|
||
}
|
||
|
||
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
|
||
const segments = pathname.split("/").filter(Boolean);
|
||
const projectsIdx = segments.indexOf("projects");
|
||
if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null;
|
||
const tab = segments[projectsIdx + 2];
|
||
if (tab === "overview") return "configuration";
|
||
if (tab === "configuration") return "configuration";
|
||
if (tab === "budget") return "budget";
|
||
if (tab === "issues") return "list";
|
||
if (tab === "plugin-operations") return "plugin-operations";
|
||
if (tab === "workspaces") return "workspaces";
|
||
return null;
|
||
}
|
||
|
||
/* ── Combined icon + color picker popover (PAP-72 / PAP-68 part 4) ── */
|
||
|
||
const DEFAULT_PROJECT_ICON = "folder";
|
||
|
||
function ProjectTilePicker({
|
||
color,
|
||
icon,
|
||
onSelectIcon,
|
||
onSelectColor,
|
||
}: {
|
||
color: string | null;
|
||
icon: string | null;
|
||
onSelectIcon: (icon: string) => void;
|
||
onSelectColor: (color: string | null) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [search, setSearch] = useState("");
|
||
|
||
const filteredIcons = useMemo(() => {
|
||
const entries = PROJECT_ICON_NAMES.map((name) => [name, PROJECT_ICONS[name]] as const);
|
||
if (!search) return entries;
|
||
const q = search.toLowerCase();
|
||
return entries.filter(([name]) => name.includes(q));
|
||
}, [search]);
|
||
|
||
// Keep the popover open across selections so the user can pick both an icon
|
||
// and a color in one pass; reset the search when it closes.
|
||
return (
|
||
<Popover
|
||
open={open}
|
||
onOpenChange={(next) => {
|
||
setOpen(next);
|
||
if (!next) setSearch("");
|
||
}}
|
||
>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
className="shrink-0 rounded-lg cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-(--tp-box-shadow)"
|
||
aria-label="Change project icon and color"
|
||
>
|
||
<ProjectTile color={color} icon={icon} size="md" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-72 p-3" align="start">
|
||
{/* Icon search + grid */}
|
||
<p className="text-xs font-medium text-muted-foreground mb-2">Icon</p>
|
||
<Input
|
||
placeholder="Search icons..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="mb-2 h-8 text-sm"
|
||
autoFocus
|
||
/>
|
||
<div className="grid grid-cols-7 gap-1 max-h-40 overflow-y-auto">
|
||
{filteredIcons.map(([name, Icon]) => (
|
||
<button
|
||
key={name}
|
||
type="button"
|
||
onClick={() => onSelectIcon(name)}
|
||
className={cn(
|
||
"flex items-center justify-center h-8 w-8 rounded hover:bg-accent transition-colors",
|
||
(icon ?? DEFAULT_PROJECT_ICON) === name && "bg-accent ring-1 ring-primary",
|
||
)}
|
||
title={name}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
</button>
|
||
))}
|
||
{filteredIcons.length === 0 && (
|
||
<p className="col-span-7 text-xs text-muted-foreground text-center py-2">No icons match</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Color swatches */}
|
||
<div className="mt-3 border-t border-border pt-3">
|
||
<p className="text-xs font-medium text-muted-foreground mb-2">Color</p>
|
||
<div className="grid grid-cols-5 gap-1.5">
|
||
{/* Neutral / reset-to-gray option */}
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectColor(null)}
|
||
className={`h-6 w-6 cursor-pointer transition-(--tp-transform-box-shadow) duration-150 hover:scale-110 ${
|
||
color === null
|
||
? "ring-2 ring-foreground ring-offset-1 ring-offset-background rounded-md"
|
||
: ""
|
||
}`}
|
||
aria-label="Reset to neutral gray"
|
||
title="Neutral (default)"
|
||
>
|
||
<ProjectTile color={null} size="sm" />
|
||
</button>
|
||
{PROJECT_COLORS.map((swatch) => (
|
||
<button
|
||
key={swatch}
|
||
type="button"
|
||
onClick={() => onSelectColor(swatch)}
|
||
className={`h-6 w-6 rounded-md cursor-pointer transition-(--tp-transform-box-shadow) duration-150 hover:scale-110 ${
|
||
swatch === color
|
||
? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
|
||
: "hover:ring-2 hover:ring-foreground/30"
|
||
}`}
|
||
style={{ backgroundColor: swatch }}
|
||
aria-label={`Select color ${swatch}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
/* ── List (issues) tab content ── */
|
||
|
||
function ProjectIssuesList({ projectId, companyId }: { projectId: string; companyId: string }) {
|
||
const showAgentCreatedTasks = useShowAgentCreatedTasks(companyId, "paperclip:project-issues-view");
|
||
const queryClient = useQueryClient();
|
||
|
||
const { data: agents } = useQuery({
|
||
queryKey: queryKeys.agents.list(companyId),
|
||
queryFn: () => agentsApi.list(companyId),
|
||
enabled: !!companyId,
|
||
});
|
||
|
||
const liveRunsQueryKey = queryKeys.liveRuns(companyId);
|
||
const sharedLiveRuns = useSharedPollingQuery({
|
||
companyId,
|
||
resourceKey: "live-runs",
|
||
queryKey: liveRunsQueryKey,
|
||
enabled: !!companyId,
|
||
// Event-sourced via LiveUpdatesProvider (issue 9627); no interval poll needed.
|
||
refetchInterval: false,
|
||
leaderOnly: true,
|
||
});
|
||
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
|
||
queryKey: liveRunsQueryKey,
|
||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||
enabled: sharedLiveRuns.enabled,
|
||
refetchInterval: sharedLiveRuns.refetchInterval,
|
||
});
|
||
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
|
||
const { data: projects } = useQuery({
|
||
...companyProjectsQuery(companyId),
|
||
enabled: !!companyId,
|
||
});
|
||
|
||
const issuesQuery = useQuery({
|
||
queryKey: [...queryKeys.issues.listByProject(companyId, projectId), showAgentCreatedTasks],
|
||
queryFn: () => issuesApi.listAll(companyId, { projectId, includeRoutineExecutions: true, includeAgentCreatedTasks: showAgentCreatedTasks }),
|
||
enabled: !!companyId,
|
||
});
|
||
const { data: issues } = issuesQuery;
|
||
const issuesView = useQueryView(issuesQuery);
|
||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
|
||
|
||
const updateIssue = useUpdateIssueMutation({
|
||
companyId,
|
||
invalidateKeys: [queryKeys.issues.listByProject(companyId, projectId), queryKeys.issues.list(companyId)],
|
||
});
|
||
|
||
if (issuesView.kind === "error") return <QueryErrorState error={issuesView.error} action="load project tasks" onRetry={issuesView.retry} retrying={issuesView.isFetching} />;
|
||
|
||
return (
|
||
<IssuesList
|
||
issues={issues ?? []}
|
||
isLoading={issuesView.kind === "loading" || issuesView.kind === "reconnecting"}
|
||
agents={agents}
|
||
projects={projects}
|
||
liveIssueIds={liveIssueIds}
|
||
projectId={projectId}
|
||
enableAgentCreatedVisibilityFilter
|
||
viewStateKey="paperclip:project-issues-view"
|
||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ProjectPluginOperationsList({
|
||
projectId,
|
||
companyId,
|
||
pluginKey,
|
||
}: {
|
||
projectId: string;
|
||
companyId: string;
|
||
pluginKey: string;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const originKindPrefix = `plugin:${pluginKey}`;
|
||
|
||
const { data: agents } = useQuery({
|
||
queryKey: queryKeys.agents.list(companyId),
|
||
queryFn: () => agentsApi.list(companyId),
|
||
enabled: !!companyId,
|
||
});
|
||
const { data: projects } = useQuery({
|
||
...companyProjectsQuery(companyId),
|
||
enabled: !!companyId,
|
||
});
|
||
const liveRunsQueryKey = queryKeys.liveRuns(companyId);
|
||
const sharedLiveRuns = useSharedPollingQuery({
|
||
companyId,
|
||
resourceKey: "live-runs",
|
||
queryKey: liveRunsQueryKey,
|
||
enabled: !!companyId,
|
||
// Event-sourced via LiveUpdatesProvider (issue 9627); no interval poll needed.
|
||
refetchInterval: false,
|
||
leaderOnly: true,
|
||
});
|
||
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
|
||
queryKey: liveRunsQueryKey,
|
||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||
enabled: sharedLiveRuns.enabled,
|
||
refetchInterval: sharedLiveRuns.refetchInterval,
|
||
});
|
||
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
|
||
const issuesQuery = useQuery({
|
||
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix),
|
||
queryFn: () => issuesApi.list(companyId, { projectId, originKindPrefix }),
|
||
enabled: !!companyId && !!projectId,
|
||
});
|
||
const { data: issues } = issuesQuery;
|
||
const issuesView = useQueryView(issuesQuery);
|
||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
|
||
|
||
const updateIssue = useUpdateIssueMutation({
|
||
companyId,
|
||
invalidateKeys: [
|
||
queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix),
|
||
queryKeys.issues.listByProject(companyId, projectId),
|
||
queryKeys.issues.list(companyId),
|
||
],
|
||
});
|
||
|
||
if (issuesView.kind === "error") return <QueryErrorState error={issuesView.error} action="load project tasks" onRetry={issuesView.retry} retrying={issuesView.isFetching} />;
|
||
|
||
return (
|
||
<IssuesList
|
||
issues={issues ?? []}
|
||
isLoading={issuesView.kind === "loading" || issuesView.kind === "reconnecting"}
|
||
agents={agents}
|
||
projects={projects}
|
||
liveIssueIds={liveIssueIds}
|
||
projectId={projectId}
|
||
viewStateKey={`paperclip:project-plugin-operations-view:${pluginKey}`}
|
||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/* ── Main project page ── */
|
||
|
||
export function ProjectDetail() {
|
||
const { companyPrefix, projectId, filter } = useParams<{
|
||
companyPrefix?: string;
|
||
projectId: string;
|
||
filter?: string;
|
||
}>();
|
||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||
const { closePanel } = usePanel();
|
||
const { setBreadcrumbs } = useBreadcrumbs();
|
||
const { pushToast } = useToastActions();
|
||
const queryClient = useQueryClient();
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const [fieldSaveStates, setFieldSaveStates] = useState<Partial<Record<ProjectConfigFieldKey, ProjectFieldSaveState>>>({});
|
||
const [dismissedLeftProjectIds, setDismissedLeftProjectIds] = useState<Set<string>>(() => new Set());
|
||
const fieldSaveRequestIds = useRef<Partial<Record<ProjectConfigFieldKey, number>>>({});
|
||
const fieldSaveTimers = useRef<Partial<Record<ProjectConfigFieldKey, ReturnType<typeof setTimeout>>>>({});
|
||
const routeProjectRef = projectId ?? "";
|
||
const routeCompanyId = useMemo(() => {
|
||
if (!companyPrefix) return null;
|
||
const requestedPrefix = companyPrefix.toUpperCase();
|
||
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null;
|
||
}, [companies, companyPrefix]);
|
||
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
||
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
|
||
const activeRouteTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
|
||
const pluginTabFromSearch = useMemo(() => {
|
||
const tab = new URLSearchParams(location.search).get("tab");
|
||
return isProjectPluginTab(tab) ? tab : null;
|
||
}, [location.search]);
|
||
const activeTab = activeRouteTab ?? pluginTabFromSearch;
|
||
|
||
const projectQuery = useQuery({
|
||
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
||
queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
|
||
enabled: canFetchProject,
|
||
// Canonicalizing the same project's URL must not unmount its edit form
|
||
// while the alias query loads. Never carry data into another company or project.
|
||
placeholderData: (previous) => previous &&
|
||
previous.companyId === lookupCompanyId &&
|
||
(previous.id === routeProjectRef || projectRouteRef(previous) === routeProjectRef)
|
||
? previous
|
||
: undefined,
|
||
});
|
||
const { data: project, isLoading, error } = projectQuery;
|
||
const projectView = useQueryView(projectQuery);
|
||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||
const projectLookupRef = project?.id ?? routeProjectRef;
|
||
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
||
const membershipsQuery = useResourceMemberships(resolvedCompanyId);
|
||
const membershipMutation = useResourceMembershipMutation(resolvedCompanyId);
|
||
const projectMembershipState = project?.id
|
||
? resourceMembershipState(membershipsQuery.data, "project", project.id)
|
||
: "joined";
|
||
const experimentalSettingsQuery = useQuery({
|
||
queryKey: queryKeys.instance.experimentalSettings,
|
||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||
});
|
||
const {
|
||
slots: pluginDetailSlots,
|
||
isLoading: pluginDetailSlotsLoading,
|
||
} = usePluginSlots({
|
||
slotTypes: ["detailTab"],
|
||
entityType: "project",
|
||
companyId: resolvedCompanyId,
|
||
enabled: !!resolvedCompanyId,
|
||
});
|
||
const pluginTabItems = useMemo(
|
||
() => pluginDetailSlots.map((slot) => ({
|
||
value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectPluginTab,
|
||
label: slot.displayName,
|
||
slot,
|
||
})),
|
||
[pluginDetailSlots],
|
||
);
|
||
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
|
||
// The slots query is disabled until the project's company resolves, and a
|
||
// disabled query reports isLoading=false — so "not loading" alone cannot
|
||
// distinguish "contribution unavailable" from "not asked yet" on cold loads.
|
||
const pluginTabDecisionLoaded = Boolean(resolvedCompanyId) && !pluginDetailSlotsLoading;
|
||
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
|
||
const workspaceTabProjectId = project?.id ?? null;
|
||
const workspaceTabIssuesQuery = useQuery({
|
||
queryKey: workspaceTabProjectId && resolvedCompanyId
|
||
? queryKeys.issues.listByProject(resolvedCompanyId, workspaceTabProjectId)
|
||
: ["issues", "__workspace-tab__", "disabled"],
|
||
queryFn: () => issuesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
|
||
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
|
||
});
|
||
const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading } = workspaceTabIssuesQuery;
|
||
const workspaceTabIssuesView = useQueryView(workspaceTabIssuesQuery);
|
||
const workspaceTabExecutionWorkspacesQuery = useQuery({
|
||
queryKey: workspaceTabProjectId && resolvedCompanyId
|
||
? queryKeys.executionWorkspaces.list(resolvedCompanyId, { projectId: workspaceTabProjectId })
|
||
: ["execution-workspaces", "__workspace-tab__", "disabled"],
|
||
queryFn: () => executionWorkspacesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }),
|
||
enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled),
|
||
});
|
||
const {
|
||
data: workspaceTabExecutionWorkspaces = [],
|
||
isLoading: isWorkspaceTabExecutionWorkspacesLoading,
|
||
} = workspaceTabExecutionWorkspacesQuery;
|
||
const workspaceTabExecutionWorkspacesView = useQueryView(workspaceTabExecutionWorkspacesQuery);
|
||
const workspaceSummaries = useMemo(() => {
|
||
if (!project || !isolatedWorkspacesEnabled) return [];
|
||
return buildProjectWorkspaceSummaries({
|
||
project,
|
||
issues: workspaceTabIssues,
|
||
executionWorkspaces: workspaceTabExecutionWorkspaces,
|
||
});
|
||
}, [project, isolatedWorkspacesEnabled, workspaceTabIssues, workspaceTabExecutionWorkspaces]);
|
||
const showWorkspacesTab = isolatedWorkspacesEnabled && workspaceSummaries.length > 0;
|
||
const workspaceTabReconnecting =
|
||
workspaceTabIssuesView.kind === "reconnecting" || workspaceTabExecutionWorkspacesView.kind === "reconnecting";
|
||
const workspaceTabDecisionLoaded =
|
||
experimentalSettingsQuery.isFetched &&
|
||
(!isolatedWorkspacesEnabled ||
|
||
(!isWorkspaceTabIssuesLoading && !isWorkspaceTabExecutionWorkspacesLoading && !workspaceTabReconnecting));
|
||
// Only a real failure shows an error; loaded summaries stay through an outage.
|
||
const workspaceTabFailure = workspaceTabIssuesView.kind === "error"
|
||
? workspaceTabIssuesView
|
||
: workspaceTabExecutionWorkspacesView.kind === "error"
|
||
? workspaceTabExecutionWorkspacesView
|
||
: null;
|
||
|
||
useEffect(() => {
|
||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||
setSelectedCompanyId(project.companyId, { source: "route_sync" });
|
||
}, [project?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||
|
||
const invalidateProject = () => {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
|
||
if (resolvedCompanyId) {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(resolvedCompanyId) });
|
||
}
|
||
};
|
||
|
||
const updateProject = useMutation({
|
||
mutationFn: (data: Record<string, unknown>) =>
|
||
projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId),
|
||
onSuccess: (_project, data) => {
|
||
invalidateProject();
|
||
if (data.visibility !== undefined) queryClient.invalidateQueries({ queryKey: ["issues"] });
|
||
},
|
||
});
|
||
|
||
const archiveProject = useMutation({
|
||
mutationFn: (archived: boolean) =>
|
||
projectsApi.update(
|
||
projectLookupRef,
|
||
{ archivedAt: archived ? new Date().toISOString() : null },
|
||
resolvedCompanyId ?? lookupCompanyId,
|
||
),
|
||
onSuccess: (updatedProject, archived) => {
|
||
invalidateProject();
|
||
const name = updatedProject?.name ?? project?.name ?? "Project";
|
||
if (archived) {
|
||
pushToast({ title: `"${name}" has been archived`, tone: "success" });
|
||
navigate("/dashboard");
|
||
} else {
|
||
pushToast({ title: `"${name}" has been unarchived`, tone: "success" });
|
||
}
|
||
},
|
||
onError: (_, archived) => {
|
||
pushToast({
|
||
title: archived ? "Failed to archive project" : "Failed to unarchive project",
|
||
tone: "error",
|
||
});
|
||
},
|
||
});
|
||
|
||
const uploadImage = useMutation({
|
||
mutationFn: async (file: File) => {
|
||
if (!resolvedCompanyId) throw new Error("No organization selected");
|
||
return assetsApi.uploadImage(resolvedCompanyId, file, `projects/${projectLookupRef || "draft"}`);
|
||
},
|
||
});
|
||
|
||
const { data: budgetOverview } = useQuery({
|
||
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
|
||
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
|
||
enabled: !!resolvedCompanyId,
|
||
refetchInterval: 30_000,
|
||
staleTime: 5_000,
|
||
});
|
||
|
||
useEffect(() => {
|
||
setBreadcrumbs([
|
||
{ label: "Projects", href: "/projects" },
|
||
{ label: project?.name ?? routeProjectRef ?? "Project" },
|
||
]);
|
||
}, [setBreadcrumbs, project, routeProjectRef]);
|
||
|
||
useEffect(() => {
|
||
if (!project) return;
|
||
if (routeProjectRef === canonicalProjectRef && !location.pathname.endsWith("/overview")) return;
|
||
if (isProjectPluginTab(activeTab)) {
|
||
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true });
|
||
return;
|
||
}
|
||
if (activeTab === "configuration") {
|
||
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
|
||
return;
|
||
}
|
||
if (activeTab === "budget") {
|
||
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
||
return;
|
||
}
|
||
if (activeTab === "plugin-operations") {
|
||
navigate(`/projects/${canonicalProjectRef}/plugin-operations`, { replace: true });
|
||
return;
|
||
}
|
||
if (activeTab === "workspaces") {
|
||
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
|
||
return;
|
||
}
|
||
if (activeTab === "list") {
|
||
if (filter) {
|
||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||
return;
|
||
}
|
||
navigate(`/projects/${canonicalProjectRef}/issues`, { replace: true });
|
||
return;
|
||
}
|
||
navigate(`/projects/${canonicalProjectRef}`, { replace: true });
|
||
}, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate, location.pathname]);
|
||
|
||
useEffect(() => {
|
||
closePanel();
|
||
return () => closePanel();
|
||
}, [closePanel]);
|
||
|
||
useEffect(() => {
|
||
if (!project?.id || projectMembershipState !== "joined") return;
|
||
setDismissedLeftProjectIds((current) => {
|
||
if (!current.has(project.id)) return current;
|
||
const next = new Set(current);
|
||
next.delete(project.id);
|
||
return next;
|
||
});
|
||
}, [project?.id, projectMembershipState]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
Object.values(fieldSaveTimers.current).forEach((timer) => {
|
||
if (timer) clearTimeout(timer);
|
||
});
|
||
};
|
||
}, []);
|
||
|
||
const setFieldState = useCallback((field: ProjectConfigFieldKey, state: ProjectFieldSaveState) => {
|
||
setFieldSaveStates((current) => ({ ...current, [field]: state }));
|
||
}, []);
|
||
|
||
const scheduleFieldReset = useCallback((field: ProjectConfigFieldKey, delayMs: number) => {
|
||
const existing = fieldSaveTimers.current[field];
|
||
if (existing) clearTimeout(existing);
|
||
fieldSaveTimers.current[field] = setTimeout(() => {
|
||
setFieldSaveStates((current) => {
|
||
const next = { ...current };
|
||
delete next[field];
|
||
return next;
|
||
});
|
||
delete fieldSaveTimers.current[field];
|
||
}, delayMs);
|
||
}, []);
|
||
|
||
const updateProjectField = useCallback(async (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
|
||
const requestId = (fieldSaveRequestIds.current[field] ?? 0) + 1;
|
||
fieldSaveRequestIds.current[field] = requestId;
|
||
setFieldState(field, "saving");
|
||
try {
|
||
await projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId);
|
||
invalidateProject();
|
||
if (data.visibility !== undefined) queryClient.invalidateQueries({ queryKey: ["issues"] });
|
||
if (fieldSaveRequestIds.current[field] !== requestId) return;
|
||
setFieldState(field, "saved");
|
||
scheduleFieldReset(field, 1800);
|
||
} catch (error) {
|
||
if (fieldSaveRequestIds.current[field] !== requestId) return;
|
||
setFieldState(field, "error");
|
||
scheduleFieldReset(field, 3000);
|
||
throw error;
|
||
}
|
||
}, [invalidateProject, lookupCompanyId, projectLookupRef, queryClient, resolvedCompanyId, scheduleFieldReset, setFieldState]);
|
||
|
||
const projectBudgetSummary = useMemo(() => {
|
||
const matched = budgetOverview?.policies.find(
|
||
(policy) => policy.scopeType === "project" && policy.scopeId === (project?.id ?? routeProjectRef),
|
||
);
|
||
if (matched) return matched;
|
||
return {
|
||
policyId: "",
|
||
companyId: resolvedCompanyId ?? "",
|
||
scopeType: "project",
|
||
scopeId: project?.id ?? routeProjectRef,
|
||
scopeName: project?.name ?? "Project",
|
||
metric: "billed_cents",
|
||
windowKind: "lifetime",
|
||
amount: 0,
|
||
unpricedEventCount: 0, pendingRunCount: 0,
|
||
unpricedUsagePolicy: "block",
|
||
observedAmount: 0,
|
||
remainingAmount: 0,
|
||
utilizationPercent: 0,
|
||
warnPercent: 80,
|
||
hardStopEnabled: true,
|
||
notifyEnabled: true,
|
||
isActive: false,
|
||
status: "ok",
|
||
paused: Boolean(project?.pausedAt),
|
||
pauseReason: project?.pauseReason ?? null,
|
||
windowStart: new Date(),
|
||
windowEnd: new Date(),
|
||
} satisfies BudgetPolicySummary;
|
||
}, [budgetOverview?.policies, project, resolvedCompanyId, routeProjectRef]);
|
||
|
||
const budgetMutation = useMutation({
|
||
mutationFn: (amount: number) =>
|
||
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
||
scopeType: "project",
|
||
scopeId: project?.id ?? routeProjectRef,
|
||
amount,
|
||
windowKind: "lifetime",
|
||
}),
|
||
onSuccess: () => {
|
||
if (!resolvedCompanyId) return;
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(resolvedCompanyId) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
|
||
},
|
||
});
|
||
|
||
if (pluginTabFromSearch && !activePluginTab && !error) {
|
||
if (!pluginTabDecisionLoaded) {
|
||
return <PageSkeleton variant="detail" />;
|
||
}
|
||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||
}
|
||
|
||
if (activeTab === "workspaces" && workspaceTabDecisionLoaded && !showWorkspacesTab) {
|
||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||
}
|
||
|
||
// Redirect bare /projects/:id to cached tab or default /issues
|
||
if (routeProjectRef && activeTab === null) {
|
||
let cachedTab: string | null = null;
|
||
if (project?.id) {
|
||
try { cachedTab = localStorage.getItem(`paperclip:project-tab:${project.id}`); } catch {}
|
||
}
|
||
if (cachedTab === "overview") {
|
||
return <Navigate to={`/projects/${canonicalProjectRef}/configuration`} replace />;
|
||
}
|
||
if (cachedTab === "configuration") {
|
||
return <Navigate to={`/projects/${canonicalProjectRef}/configuration`} replace />;
|
||
}
|
||
if (cachedTab === "budget") {
|
||
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
||
}
|
||
if (cachedTab === "plugin-operations" && project?.managedByPlugin) {
|
||
return <Navigate to={`/projects/${canonicalProjectRef}/plugin-operations`} replace />;
|
||
}
|
||
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
|
||
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
|
||
}
|
||
if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) {
|
||
return <PageSkeleton variant="detail" />;
|
||
}
|
||
if (isProjectPluginTab(cachedTab)) {
|
||
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
|
||
}
|
||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||
}
|
||
|
||
if (isLoading || projectView.kind === "reconnecting") return <PageSkeleton variant="detail" />;
|
||
if (projectView.kind === "error") {
|
||
return <QueryErrorState size="page" error={error} action="load this project" onRetry={projectView.retry} retrying={projectView.isFetching} />;
|
||
}
|
||
if (!project) return null;
|
||
const showLeftProjectNotice =
|
||
projectMembershipState === "left" && !dismissedLeftProjectIds.has(project.id);
|
||
const projectMembershipPending =
|
||
membershipMutation.isPending &&
|
||
membershipMutation.variables?.resourceType === "project" &&
|
||
membershipMutation.variables.resourceId === project.id;
|
||
const projectStarred = isStarred(membershipsQuery.data, "project", project.id);
|
||
const projectStarPending = projectMembershipPending && membershipMutation.variables?.starred !== undefined;
|
||
const projectJoinLeavePending = projectMembershipPending && membershipMutation.variables?.starred === undefined;
|
||
|
||
const handleTabChange = (tab: ProjectTab) => {
|
||
// Cache the active tab per project
|
||
if (project?.id) {
|
||
try { localStorage.setItem(`paperclip:project-tab:${project.id}`, tab); } catch {}
|
||
}
|
||
if (isProjectPluginTab(tab)) {
|
||
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
|
||
return;
|
||
}
|
||
if (tab === "workspaces") {
|
||
navigate(`/projects/${canonicalProjectRef}/workspaces`);
|
||
} else if (tab === "budget") {
|
||
navigate(`/projects/${canonicalProjectRef}/budget`);
|
||
} else if (tab === "plugin-operations") {
|
||
navigate(`/projects/${canonicalProjectRef}/plugin-operations`);
|
||
} else if (tab === "configuration") {
|
||
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
||
} else {
|
||
navigate(`/projects/${canonicalProjectRef}/issues`);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{showLeftProjectNotice ? (
|
||
<div className="flex items-center gap-3 border border-yellow-300/35 bg-yellow-300/10 px-3 py-2 text-sm text-yellow-900 dark:text-yellow-100">
|
||
<p className="min-w-0 flex-1">
|
||
You left this project. It no longer appears in your sidebar.
|
||
</p>
|
||
<MembershipAction
|
||
compact
|
||
state="left"
|
||
pending={projectJoinLeavePending}
|
||
pendingState={projectJoinLeavePending ? 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",
|
||
})}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="h-6 w-6 shrink-0 text-yellow-900/70 hover:text-yellow-900 dark:text-yellow-100/70 dark:hover:text-yellow-100"
|
||
aria-label="Dismiss project membership notice"
|
||
onClick={() => setDismissedLeftProjectIds((current) => new Set(current).add(project.id))}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
<div className="flex items-start gap-3">
|
||
<div className="h-7 flex items-center">
|
||
<ProjectTilePicker
|
||
color={project.color ?? null}
|
||
icon={project.icon ?? null}
|
||
onSelectIcon={(icon) => updateProject.mutate({ icon })}
|
||
onSelectColor={(color) => updateProject.mutate({ color })}
|
||
/>
|
||
</div>
|
||
<div className="min-w-0 space-y-2">
|
||
<InlineEditor
|
||
value={project.name}
|
||
onSave={(name) => updateProject.mutate({ name })}
|
||
as="h2"
|
||
className="text-xl font-bold"
|
||
/>
|
||
{project.pauseReason === "budget" ? (
|
||
<div className="inline-flex items-center gap-2 rounded-full border border-red-500/30 bg-red-500/10 px-3 py-1 text-(length:--text-micro) font-medium uppercase tracking-(--tracking-caps) text-red-800 dark:text-red-200">
|
||
<span className="h-2 w-2 rounded-full bg-red-400" />
|
||
Paused by budget hard stop
|
||
</div>
|
||
) : null}
|
||
{project.managedByPlugin ? (
|
||
<div className="inline-flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1 text-(length:--text-micro) font-medium text-muted-foreground">
|
||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: project.color ?? "var(--project-seed)" }} />
|
||
Managed by {project.managedByPlugin.pluginDisplayName}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<StarToggle
|
||
size="button"
|
||
starred={projectStarred}
|
||
pending={projectStarPending}
|
||
resourceName={project.name}
|
||
onToggle={(next) => membershipMutation.mutate({
|
||
resourceType: "project",
|
||
resourceId: project.id,
|
||
resourceName: project.name,
|
||
starred: next,
|
||
})}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<SummarySlotCard
|
||
companyId={resolvedCompanyId}
|
||
scopeKind="project"
|
||
scopeId={project.id}
|
||
title="Project summary"
|
||
description="Summarizer keeps the latest project status, next step, and operator-needed items here."
|
||
/>
|
||
|
||
<PluginSlotOutlet
|
||
slotTypes={["toolbarButton", "contextMenuItem"]}
|
||
entityType="project"
|
||
context={{
|
||
companyId: resolvedCompanyId ?? null,
|
||
companyPrefix: companyPrefix ?? null,
|
||
projectId: project.id,
|
||
projectRef: canonicalProjectRef,
|
||
entityId: project.id,
|
||
entityType: "project",
|
||
}}
|
||
className="flex flex-wrap gap-2"
|
||
itemClassName="inline-flex"
|
||
missingBehavior="placeholder"
|
||
/>
|
||
|
||
<PluginLauncherOutlet
|
||
placementZones={["toolbarButton"]}
|
||
entityType="project"
|
||
context={{
|
||
companyId: resolvedCompanyId ?? null,
|
||
companyPrefix: companyPrefix ?? null,
|
||
projectId: project.id,
|
||
projectRef: canonicalProjectRef,
|
||
entityId: project.id,
|
||
entityType: "project",
|
||
}}
|
||
className="flex flex-wrap gap-2"
|
||
itemClassName="inline-flex"
|
||
/>
|
||
|
||
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
|
||
<PageTabBar
|
||
items={[
|
||
{ value: "list", label: "Tasks" },
|
||
|
||
...(project.managedByPlugin ? [{ value: "plugin-operations", label: "Plugin operations" }] : []),
|
||
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
|
||
{ value: "configuration", label: "Configuration" },
|
||
{ value: "budget", label: "Budget" },
|
||
...pluginTabItems.map((item) => ({
|
||
value: item.value,
|
||
label: item.label,
|
||
})),
|
||
]}
|
||
align="start"
|
||
value={activeTab ?? "list"}
|
||
onValueChange={(value) => handleTabChange(value as ProjectTab)}
|
||
/>
|
||
</Tabs>
|
||
|
||
|
||
|
||
{activeTab === "list" && project?.id && resolvedCompanyId && (
|
||
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
||
)}
|
||
|
||
{activeTab === "plugin-operations" && project?.id && resolvedCompanyId && project.managedByPlugin && (
|
||
<ProjectPluginOperationsList
|
||
projectId={project.id}
|
||
companyId={resolvedCompanyId}
|
||
pluginKey={project.managedByPlugin.pluginKey}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === "workspaces" ? (
|
||
workspaceTabDecisionLoaded ? (
|
||
workspaceTabFailure ? (
|
||
<QueryErrorState
|
||
error={workspaceTabFailure.error}
|
||
action="load workspaces"
|
||
onRetry={workspaceTabFailure.retry}
|
||
retrying={workspaceTabFailure.isFetching}
|
||
/>
|
||
) : (
|
||
<ProjectWorkspacesContent
|
||
companyId={resolvedCompanyId!}
|
||
projectId={project.id}
|
||
projectRef={canonicalProjectRef}
|
||
summaries={workspaceSummaries}
|
||
/>
|
||
)
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">Loading workspaces...</p>
|
||
)
|
||
) : null}
|
||
|
||
{activeTab === "configuration" && (
|
||
<div className="max-w-4xl">
|
||
<ProjectProperties
|
||
project={project}
|
||
onUpdate={(data) => updateProject.mutate(data)}
|
||
onFieldUpdate={updateProjectField}
|
||
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
|
||
onArchive={(archived) => archiveProject.mutate(archived)}
|
||
archivePending={archiveProject.isPending}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "budget" && resolvedCompanyId ? (
|
||
<div className="max-w-3xl">
|
||
<BudgetPolicyCard
|
||
summary={projectBudgetSummary}
|
||
variant="plain"
|
||
isSaving={budgetMutation.isPending}
|
||
onSave={(amount) => budgetMutation.mutate(amount)}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{activePluginTab && (
|
||
<PluginSlotMount
|
||
slot={activePluginTab.slot}
|
||
context={{
|
||
companyId: resolvedCompanyId,
|
||
companyPrefix: companyPrefix ?? null,
|
||
projectId: project.id,
|
||
projectRef: canonicalProjectRef,
|
||
entityId: project.id,
|
||
entityType: "project",
|
||
}}
|
||
missingBehavior="placeholder"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|