Files
PaperClipAI/ui/src/pages/ProjectDetail.tsx
T
Nicky LeachandPaperclip 01b51dc0e5 fix(runtime): stop Live badge and Working shimmer after task teardown (#10985)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task list and the chat views show a Live badge and a Working
shimmer for an issue that has an active run.
> - A finished task kept the Live badge and the Working shimmer after
the run ended and the sandbox stopped.
> - The user interface reads run liveness from the
`heartbeat_runs.status` row. The run finalizer writes the terminal
status in a step that is separate from the agent `status=done` update.
When the sandbox or the run process stops between the two steps,
`heartbeat_runs.status` stays `running` forever.
> - A run row that stays `running` makes a finished task look
perpetually Live, and the user interface has no guard for an issue that
already reached a terminal status.
> - This pull request closes the invariant "environment lease released
implies the run is terminal" on the server, and adds a user interface
guard that suppresses live state for a terminal issue.
> - The benefit is that a finished task stops showing Live and Working,
both at the source (the run row) and at the surface (the badge and the
shimmer).

## Linked Issues or Issue Description

**Bug description**

- A completed task kept the Live badge and the Working shimmer after its
run ended and the sandbox was torn down.

**Steps to reproduce**

- Run an agent task to completion. Let the sandbox tear down while the
run finalizer is between the `status=done` update and the terminal
run-status write.
- Open the task list or the chat view for the finished task.

**Expected behavior**

- A finished task shows no Live badge and no Working shimmer.

**Actual behavior (before this change)**

- The finished task showed the Live badge and the Working shimmer
because its `heartbeat_runs.status` row stayed `running`.

This pull request supersedes the two separate pull requests #10954
(frontend) and #10955 (backend). It carries all of their changes for the
same race.

## What Changed

Server:

- Run teardown terminalizes a still-running or still-queued run before
it releases the environment lease. It writes `succeeded` when the issue
already reached `done`, `cancelled` when the issue is `cancelled`, and
`interrupted` otherwise. It never overwrites a status that another path
already made terminal.
- The recovery stale-lock sweep terminalizes an orphaned running run to
`interrupted` after it confirms the process and the sandbox are both
gone. It requires recorded process metadata, so it never terminalizes a
live run, a queued run, or a scheduled retry.
- Each terminal transition writes a run event.
- The stale-lock sweep continues and clears the lock when the audit
write fails. It logs the failure loudly.
- New server tests cover both invariants.

User interface:

- A shared guard suppresses the Live badge and the Working shimmer when
the issue status is terminal.
- The guard keeps non-terminal `queued` and `running` issues live.
- The guard prefers the newest issue live-status snapshot.
- New user interface tests cover the guard and the snapshot preference.

## Verification

Server:

- `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc
--noEmit` in `server/` — 0 errors.
- `pnpm --filter server test
heartbeat-run-lease-release-terminalization.test.ts
recovery-stale-issue-lock-sweep.test.ts` — 12 tests pass.

User interface:

- `pnpm --filter @paperclipai/ui typecheck` — 0 errors.
- `pnpm exec vitest run ui/src/lib/liveIssueIds.test.ts
ui/src/lib/issue-chat-messages.test.ts` — 40 tests pass.

## Risks

- Low risk. The server change only forces a still-live run row to a
terminal status when the lease releases or when the recovery sweep
confirms the process is dead. It never overwrites an existing terminal
status, and it guards the recovery path with process metadata to avoid
terminalizing a live run.
- The user interface change is additive. The guard only suppresses live
state for a terminal issue and keeps queued and running issues live.
- No database migration. No change to any external endpoint.

## Model Used

- Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, tool use, and
code execution.

## 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
- [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: Paperclip <noreply@paperclip.ing>
2026-08-07 07:23:56 -07:00

972 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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";
/* ── 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-(--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 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({
queryKey: queryKeys.projects.list(companyId),
queryFn: () => projectsApi.list(companyId),
enabled: !!companyId,
});
const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.listByProject(companyId, projectId),
queryFn: () => issuesApi.list(companyId, { projectId }),
enabled: !!companyId,
});
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
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 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: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix),
queryFn: () => issuesApi.list(companyId, { projectId, originKindPrefix }),
enabled: !!companyId && !!projectId,
});
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
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.all(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.all(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 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 === "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-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" },
{ 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>
);
}