2e74d32871
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board UI is the operator surface where users create, assign, monitor, and review work items. > - The product language is moving toward "tasks" for user-facing work items while the internal API and database still use "issues". > - PR #7543 bundled this copy migration with broader information-architecture work, which made the branch too large for Greptile review. > - This pull request peels the Issue-to-Task copy migration into a smaller, independently reviewable change. > - The benefit is clearer user-facing terminology, less agent confusion via the Paperclip skill note, and a smaller PR that Greptile can review. ## Linked Issues or Issue Description Refs #7645 Refs #7543 Refs PAP-10430 This PR was split out of #7543 so the Issue-to-Task copy migration can be reviewed separately and the remaining IA PR can fall under Greptile's file limit. ## What Changed - Preserves Scott Tong's original `PAP-57` copy-only commit, with author and co-author credit intact, to rename user-facing "Issues" copy to "Tasks" across the UI while keeping routes/API/internal symbols as `issue`. - Updates onboarding and release-smoke browser selectors from `Create & Open Issue` to `Create & Open Task`. - Adds a terminology note to `skills/paperclip/SKILL.md` clarifying that task and issue refer to the same Paperclip work item. - Resolves the only cherry-pick conflict by keeping current search artifacts support and changing visible search copy to "tasks". ## Verification - `pnpm --filter @paperclipai/ui build` passed. - `NODE_ENV=test pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/Sidebar.test.tsx ui/src/components/NewIssueDialog.test.tsx ui/src/pages/IssueDetail.test.tsx` passed: 4 files, 66 tests. - `git diff --check origin/master...HEAD` passed. - Diff is 80 files, below Greptile's 100-file limit. - Before/after UI copy examples: "Issues" -> "Tasks", "New Issue" -> "New Task", "Create & Open Issue" -> "Create & Open Task". ## Risks - Medium copy-risk: this intentionally changes user-facing terminology broadly while keeping internal issue identifiers and routes unchanged. - Some docs and APIs still say `issue`; the skill note clarifies this so agents do not treat task and issue as separate entities. - Browser-level visual validation is expected from CI because this local container is missing usable browser dependencies. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Scott Tong authored the original `PAP-57` copy migration, assisted by Claude Opus 4.8 and Paperclip agents per the preserved commit metadata. Codex / GPT-5-class coding agent with shell, GitHub CLI, and repository access performed the PR split, conflict resolution, skill note, and verification. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: scotttong <scott.tong@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
923 lines
35 KiB
TypeScript
923 lines
35 KiB
TypeScript
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 { PageTabBar } from "../components/PageTabBar";
|
||
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
|
||
import { MembershipAction } from "../components/MembershipAction";
|
||
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 { 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 {
|
||
resourceMembershipState,
|
||
useResourceMembershipMutation,
|
||
useResourceMemberships,
|
||
} from "../hooks/useResourceMemberships";
|
||
|
||
/* ── Top-level tab types ── */
|
||
|
||
type ProjectBaseTab = "overview" | "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 "overview";
|
||
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;
|
||
}
|
||
|
||
/* ── Overview tab content ── */
|
||
|
||
function OverviewContent({
|
||
project,
|
||
onUpdate,
|
||
imageUploadHandler,
|
||
}: {
|
||
project: { description: string | null; status: string; targetDate: string | null };
|
||
onUpdate: (data: Record<string, unknown>) => void;
|
||
imageUploadHandler?: (file: File) => Promise<string>;
|
||
}) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<InlineEditor
|
||
value={project.description ?? ""}
|
||
onSave={(description) => onUpdate({ description })}
|
||
nullable
|
||
as="p"
|
||
className="text-sm text-muted-foreground"
|
||
placeholder="Add a description..."
|
||
multiline
|
||
imageUploadHandler={imageUploadHandler}
|
||
/>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-muted-foreground">Status</span>
|
||
<div className="mt-1">
|
||
<StatusBadge status={project.status} />
|
||
</div>
|
||
</div>
|
||
{project.targetDate && (
|
||
<div>
|
||
<span className="text-muted-foreground">Target Date</span>
|
||
<p>{project.targetDate}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── 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-[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-[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-[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 queryClient = useQueryClient();
|
||
|
||
const { data: agents } = useQuery({
|
||
queryKey: queryKeys.agents.list(companyId),
|
||
queryFn: () => agentsApi.list(companyId),
|
||
enabled: !!companyId,
|
||
});
|
||
|
||
const { data: liveRuns } = useQuery({
|
||
queryKey: queryKeys.liveRuns(companyId),
|
||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||
enabled: !!companyId,
|
||
refetchInterval: 5000,
|
||
});
|
||
const { data: projects } = useQuery({
|
||
queryKey: queryKeys.projects.list(companyId),
|
||
queryFn: () => projectsApi.list(companyId),
|
||
enabled: !!companyId,
|
||
});
|
||
|
||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||
|
||
const { data: issues, isLoading, error } = useQuery({
|
||
queryKey: queryKeys.issues.listByProject(companyId, projectId),
|
||
queryFn: () => issuesApi.list(companyId, { projectId }),
|
||
enabled: !!companyId,
|
||
});
|
||
|
||
const updateIssue = useMutation({
|
||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||
issuesApi.update(id, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||
},
|
||
});
|
||
|
||
return (
|
||
<IssuesList
|
||
issues={issues ?? []}
|
||
isLoading={isLoading}
|
||
error={error as Error | null}
|
||
agents={agents}
|
||
projects={projects}
|
||
liveIssueIds={liveIssueIds}
|
||
projectId={projectId}
|
||
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({
|
||
queryKey: queryKeys.projects.list(companyId),
|
||
queryFn: () => projectsApi.list(companyId),
|
||
enabled: !!companyId,
|
||
});
|
||
const { data: liveRuns } = useQuery({
|
||
queryKey: queryKeys.liveRuns(companyId),
|
||
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||
enabled: !!companyId,
|
||
refetchInterval: 5000,
|
||
});
|
||
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||
|
||
const { data: issues, isLoading, error } = useQuery({
|
||
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix),
|
||
queryFn: () => issuesApi.list(companyId, { projectId, originKindPrefix }),
|
||
enabled: !!companyId && !!projectId,
|
||
});
|
||
|
||
const updateIssue = useMutation({
|
||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||
issuesApi.update(id, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||
},
|
||
});
|
||
|
||
return (
|
||
<IssuesList
|
||
issues={issues ?? []}
|
||
isLoading={isLoading}
|
||
error={error as Error | null}
|
||
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 { data: project, isLoading, error } = useQuery({
|
||
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
||
queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
|
||
enabled: canFetchProject,
|
||
});
|
||
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;
|
||
const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true;
|
||
const workspaceTabProjectId = project?.id ?? null;
|
||
const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = 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: workspaceTabExecutionWorkspaces = [],
|
||
isLoading: isWorkspaceTabExecutionWorkspacesLoading,
|
||
error: workspaceTabExecutionWorkspacesError,
|
||
} = 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 workspaceSummaries = useMemo(() => {
|
||
if (!project || !isolatedWorkspacesEnabled) return [];
|
||
return buildProjectWorkspaceSummaries({
|
||
project,
|
||
issues: workspaceTabIssues,
|
||
executionWorkspaces: workspaceTabExecutionWorkspaces,
|
||
});
|
||
}, [project, isolatedWorkspacesEnabled, workspaceTabIssues, workspaceTabExecutionWorkspaces]);
|
||
const showWorkspacesTab = isolatedWorkspacesEnabled && workspaceSummaries.length > 0;
|
||
const workspaceTabDecisionLoaded =
|
||
experimentalSettingsQuery.isFetched &&
|
||
(!isolatedWorkspacesEnabled || (!isWorkspaceTabIssuesLoading && !isWorkspaceTabExecutionWorkspacesLoading));
|
||
const workspaceTabError = (workspaceTabIssuesError ?? workspaceTabExecutionWorkspacesError) as Error | 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.list(resolvedCompanyId) });
|
||
}
|
||
};
|
||
|
||
const updateProject = useMutation({
|
||
mutationFn: (data: Record<string, unknown>) =>
|
||
projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId),
|
||
onSuccess: invalidateProject,
|
||
});
|
||
|
||
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 company 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) return;
|
||
if (isProjectPluginTab(activeTab)) {
|
||
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true });
|
||
return;
|
||
}
|
||
if (activeTab === "overview") {
|
||
navigate(`/projects/${canonicalProjectRef}/overview`, { 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]);
|
||
|
||
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 (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, 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,
|
||
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.list(resolvedCompanyId) });
|
||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
|
||
},
|
||
});
|
||
|
||
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
|
||
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}/overview`} 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) return <PageSkeleton variant="detail" />;
|
||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||
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 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 === "overview") {
|
||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||
} else 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-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={projectMembershipPending}
|
||
pendingState={projectMembershipPending ? 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-100/70 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-[11px] font-medium uppercase tracking-[0.18em] 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-[11px] font-medium text-muted-foreground">
|
||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: project.color ?? "#6366f1" }} />
|
||
Managed by {project.managedByPlugin.pluginDisplayName}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<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" },
|
||
{ value: "overview", label: "Overview" },
|
||
...(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 === "overview" && (
|
||
<OverviewContent
|
||
project={project}
|
||
onUpdate={(data) => updateProject.mutate(data)}
|
||
imageUploadHandler={async (file) => {
|
||
const asset = await uploadImage.mutateAsync(file);
|
||
return asset.contentPath;
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{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 ? (
|
||
workspaceTabError ? (
|
||
<p className="text-sm text-destructive">{workspaceTabError.message}</p>
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|