mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
fixes https://github.com/paperclipai/paperclip/issues/2336 ## Thinking Path <!-- Required. Trace your reasoning from the top of the project down to this specific change. Start with what Paperclip is, then narrow through the subsystem, the problem, and why this PR exists. Use blockquote style. Aim for 5–8 steps. See CONTRIBUTING.md for full examples. --> - Paperclip allows to manage projects - During the project creation you can optionally enter a description - In the project overview or configuration you can edit the description - However, you cannot remove the description - The user should be able to remove the project description because it's an optional property - This pull request fixes the frontend bug that prevented the user to remove/clear the project description ## What Changed <!-- Bullet list of concrete changes. One bullet per logical unit. --> - project description can be cleared in "project configuration" and "project overview" ## Verification <!-- How can a reviewer confirm this works? Include test commands, manual steps, or both. For UI changes, include before/after screenshots. --> In project configuration or project overview: - In the description field remove/clear the text ## Risks <!-- What could go wrong? Mention migration safety, breaking changes, behavioral shifts, or "Low risk" if genuinely minor. --> - none ## Checklist - [x] I have included a thinking path that traces from project context to this change - [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] I will address all Greptile and reviewer comments before requesting merge
942 lines
36 KiB
TypeScript
942 lines
36 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, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace } 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 { useToast } from "../context/ToastContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
|
import { CopyText } from "../components/CopyText";
|
|
import { InlineEditor } from "../components/InlineEditor";
|
|
import { StatusBadge } from "../components/StatusBadge";
|
|
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
|
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
|
|
import { IssuesList } from "../components/IssuesList";
|
|
import { PageSkeleton } from "../components/PageSkeleton";
|
|
import { PageTabBar } from "../components/PageTabBar";
|
|
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
|
import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tabs } from "@/components/ui/tabs";
|
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
|
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
|
import { Copy, FolderOpen, GitBranch, Loader2, Play, Square } from "lucide-react";
|
|
import { IssuesQuicklook } from "../components/IssuesQuicklook";
|
|
|
|
/* ── Top-level tab types ── */
|
|
|
|
type ProjectBaseTab = "overview" | "list" | "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 === "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>
|
|
);
|
|
}
|
|
|
|
/* ── Color picker popover ── */
|
|
|
|
function ColorPicker({
|
|
currentColor,
|
|
onSelect,
|
|
}: {
|
|
currentColor: string;
|
|
onSelect: (color: string) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handleClick(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [open]);
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
|
|
style={{ backgroundColor: currentColor }}
|
|
aria-label="Change project color"
|
|
/>
|
|
{open && (
|
|
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
|
|
<div className="grid grid-cols-5 gap-1.5">
|
|
{PROJECT_COLORS.map((color) => (
|
|
<button
|
|
key={color}
|
|
onClick={() => {
|
|
onSelect(color);
|
|
setOpen(false);
|
|
}}
|
|
className={`h-6 w-6 rounded-md cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
|
|
color === currentColor
|
|
? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
|
|
: "hover:ring-2 hover:ring-foreground/30"
|
|
}`}
|
|
style={{ backgroundColor: color }}
|
|
aria-label={`Select color ${color}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── 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 liveIssueIds = useMemo(() => {
|
|
const ids = new Set<string>();
|
|
for (const run of liveRuns ?? []) {
|
|
if (run.issueId) ids.add(run.issueId);
|
|
}
|
|
return ids;
|
|
}, [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}
|
|
liveIssueIds={liveIssueIds}
|
|
projectId={projectId}
|
|
viewStateKey={`paperclip:project-view:${projectId}`}
|
|
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProjectWorkspacesContent({
|
|
companyId,
|
|
projectId,
|
|
projectRef,
|
|
summaries,
|
|
}: {
|
|
companyId: string;
|
|
projectId: string;
|
|
projectRef: string;
|
|
summaries: ReturnType<typeof buildProjectWorkspaceSummaries>;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
const [runtimeActionKey, setRuntimeActionKey] = useState<string | null>(null);
|
|
const [closingWorkspace, setClosingWorkspace] = useState<{
|
|
id: string;
|
|
name: string;
|
|
status: ExecutionWorkspace["status"];
|
|
} | null>(null);
|
|
const controlWorkspaceRuntime = useMutation({
|
|
mutationFn: async (input: {
|
|
key: string;
|
|
kind: "project_workspace" | "execution_workspace";
|
|
workspaceId: string;
|
|
action: "start" | "stop" | "restart";
|
|
}) => {
|
|
setRuntimeActionKey(`${input.key}:${input.action}`);
|
|
if (input.kind === "project_workspace") {
|
|
return await projectsApi.controlWorkspaceRuntimeServices(projectId, input.workspaceId, input.action, companyId);
|
|
}
|
|
return await executionWorkspacesApi.controlRuntimeServices(input.workspaceId, input.action);
|
|
},
|
|
onSettled: () => {
|
|
setRuntimeActionKey(null);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
|
},
|
|
});
|
|
|
|
if (summaries.length === 0) {
|
|
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
|
}
|
|
|
|
const activeSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus !== "cleanup_failed");
|
|
const cleanupFailedSummaries = summaries.filter((summary) => summary.executionWorkspaceStatus === "cleanup_failed");
|
|
|
|
const renderSummaryRow = (summary: ReturnType<typeof buildProjectWorkspaceSummaries>[number]) => {
|
|
const visibleIssues = summary.issues.slice(0, 5);
|
|
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
|
|
const workspaceHref =
|
|
summary.kind === "project_workspace"
|
|
? projectWorkspaceUrl({ id: projectRef, urlKey: projectRef }, summary.workspaceId)
|
|
: `/execution-workspaces/${summary.workspaceId}`;
|
|
const hasRunningServices = summary.runningServiceCount > 0;
|
|
|
|
const truncatePath = (path: string) => {
|
|
const parts = path.split("/").filter(Boolean);
|
|
if (parts.length <= 3) return path;
|
|
return `…/${parts.slice(-2).join("/")}`;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={summary.key}
|
|
className="border-b border-border px-4 py-3 last:border-b-0"
|
|
>
|
|
{/* Header row: name + actions */}
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
to={workspaceHref}
|
|
className="min-w-0 shrink truncate text-sm font-medium hover:underline"
|
|
>
|
|
{summary.workspaceName}
|
|
</Link>
|
|
|
|
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
|
{summary.serviceCount > 0 ? (
|
|
<span className={`inline-flex items-center gap-1 ${hasRunningServices ? "text-emerald-500" : ""}`}>
|
|
<span className={`inline-block h-1.5 w-1.5 rounded-full ${hasRunningServices ? "bg-emerald-500" : "bg-muted-foreground/40"}`} />
|
|
{summary.runningServiceCount}/{summary.serviceCount}
|
|
</span>
|
|
) : null}
|
|
{summary.executionWorkspaceStatus && summary.executionWorkspaceStatus !== "active" ? (
|
|
<span className="text-[11px] text-muted-foreground">{summary.executionWorkspaceStatus}</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">{timeAgo(summary.lastUpdatedAt)}</span>
|
|
{summary.hasRuntimeConfig ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 gap-1.5 px-2 text-xs"
|
|
disabled={controlWorkspaceRuntime.isPending}
|
|
onClick={() =>
|
|
controlWorkspaceRuntime.mutate({
|
|
key: summary.key,
|
|
kind: summary.kind,
|
|
workspaceId: summary.workspaceId,
|
|
action: hasRunningServices ? "stop" : "start",
|
|
})
|
|
}
|
|
>
|
|
{runtimeActionKey === `${summary.key}:start` || runtimeActionKey === `${summary.key}:stop` ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : hasRunningServices ? (
|
|
<Square className="h-3 w-3" />
|
|
) : (
|
|
<Play className="h-3 w-3" />
|
|
)}
|
|
{hasRunningServices ? "Stop" : "Start"}
|
|
</Button>
|
|
) : null}
|
|
{summary.kind === "execution_workspace" && summary.executionWorkspaceId && summary.executionWorkspaceStatus ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs text-muted-foreground"
|
|
onClick={() => setClosingWorkspace({
|
|
id: summary.executionWorkspaceId!,
|
|
name: summary.workspaceName,
|
|
status: summary.executionWorkspaceStatus!,
|
|
})}
|
|
>
|
|
{summary.executionWorkspaceStatus === "cleanup_failed" ? "Retry close" : "Close"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metadata lines: branch, folder */}
|
|
<div className="mt-1.5 space-y-0.5 text-xs text-muted-foreground">
|
|
{summary.branchName ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<GitBranch className="h-3 w-3 shrink-0" />
|
|
<span className="font-mono">{summary.branchName}</span>
|
|
</div>
|
|
) : null}
|
|
{summary.cwd ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<FolderOpen className="h-3 w-3 shrink-0" />
|
|
<span className="truncate font-mono" title={summary.cwd}>
|
|
{truncatePath(summary.cwd)}
|
|
</span>
|
|
<CopyText text={summary.cwd} className="shrink-0" copiedLabel="Path copied">
|
|
<Copy className="h-3 w-3" />
|
|
</CopyText>
|
|
</div>
|
|
) : null}
|
|
{summary.primaryServiceUrl ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<a
|
|
href={summary.primaryServiceUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="font-mono hover:text-foreground hover:underline"
|
|
>
|
|
{summary.primaryServiceUrl}
|
|
</a>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Issues */}
|
|
{summary.issues.length > 0 ? (
|
|
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
|
<span className="font-medium text-muted-foreground/70">Issues</span>
|
|
{visibleIssues.map((issue) => (
|
|
<IssuesQuicklook key={issue.id} issue={issue}>
|
|
<Link
|
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
|
className="font-mono hover:text-foreground hover:underline"
|
|
>
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
</Link>
|
|
</IssuesQuicklook>
|
|
))}
|
|
{hiddenIssueCount > 0 ? (
|
|
<Link to={workspaceHref} className="hover:text-foreground hover:underline">
|
|
+{hiddenIssueCount} more
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-4">
|
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
|
{activeSummaries.map(renderSummaryRow)}
|
|
</div>
|
|
{cleanupFailedSummaries.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
Cleanup attention needed
|
|
</div>
|
|
<div className="overflow-hidden rounded-xl border border-amber-500/20 bg-amber-500/5">
|
|
{cleanupFailedSummaries.map(renderSummaryRow)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{closingWorkspace ? (
|
|
<ExecutionWorkspaceCloseDialog
|
|
workspaceId={closingWorkspace.id}
|
|
workspaceName={closingWorkspace.name}
|
|
currentStatus={closingWorkspace.status}
|
|
open
|
|
onOpenChange={(open) => {
|
|
if (!open) setClosingWorkspace(null);
|
|
}}
|
|
onClosed={() => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.list(companyId, { projectId }) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
|
setClosingWorkspace(null);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ── 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 } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [fieldSaveStates, setFieldSaveStates] = useState<Partial<Record<ProjectConfigFieldKey, ProjectFieldSaveState>>>({});
|
|
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 experimentalSettingsQuery = useQuery({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
retry: false,
|
|
});
|
|
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 === "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(() => {
|
|
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 === "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 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 === "configuration") {
|
|
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
|
} else {
|
|
navigate(`/projects/${canonicalProjectRef}/issues`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-start gap-3">
|
|
<div className="h-7 flex items-center">
|
|
<ColorPicker
|
|
currentColor={project.color ?? "#6366f1"}
|
|
onSelect={(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}
|
|
</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: "Issues" },
|
|
{ value: "overview", label: "Overview" },
|
|
...(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 === "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>
|
|
);
|
|
}
|