2026-03-10 10:04:08 -05:00
|
|
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
2026-03-26 13:00:25 -05:00
|
|
|
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
2026-02-20 10:32:32 -06:00
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
2026-03-26 16:17:33 -05:00
|
|
|
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
2026-03-14 22:00:12 -05:00
|
|
|
import { budgetsApi } from "../api/budgets";
|
2026-03-26 13:00:25 -05:00
|
|
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
|
|
|
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
import { projectsApi } from "../api/projects";
|
|
|
|
|
import { issuesApi } from "../api/issues";
|
2026-02-23 09:56:31 -06:00
|
|
|
import { agentsApi } from "../api/agents";
|
|
|
|
|
import { heartbeatsApi } from "../api/heartbeats";
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
import { assetsApi } from "../api/assets";
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
import { usePanel } from "../context/PanelContext";
|
|
|
|
|
import { useCompany } from "../context/CompanyContext";
|
2026-03-16 18:46:26 -05:00
|
|
|
import { useToast } from "../context/ToastContext";
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
2026-02-17 12:24:48 -06:00
|
|
|
import { queryKeys } from "../lib/queryKeys";
|
2026-03-10 10:04:08 -05:00
|
|
|
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
2026-02-20 10:32:32 -06:00
|
|
|
import { InlineEditor } from "../components/InlineEditor";
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
import { StatusBadge } from "../components/StatusBadge";
|
2026-03-14 22:00:12 -05:00
|
|
|
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
2026-02-23 09:56:31 -06:00
|
|
|
import { IssuesList } from "../components/IssuesList";
|
2026-03-02 16:44:03 -06:00
|
|
|
import { PageSkeleton } from "../components/PageSkeleton";
|
2026-03-10 09:08:20 -05:00
|
|
|
import { PageTabBar } from "../components/PageTabBar";
|
2026-03-26 13:00:25 -05:00
|
|
|
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
|
|
|
|
import { projectRouteRef } from "../lib/utils";
|
|
|
|
|
import { timeAgo } from "../lib/timeAgo";
|
2026-03-10 09:08:20 -05:00
|
|
|
import { Tabs } from "@/components/ui/tabs";
|
2026-03-13 23:03:51 -05:00
|
|
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
|
|
|
|
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
2026-03-26 13:00:25 -05:00
|
|
|
import { Clock3, GitBranch, Rows3 } from "lucide-react";
|
2026-02-23 09:56:31 -06:00
|
|
|
|
|
|
|
|
/* ── Top-level tab types ── */
|
|
|
|
|
|
2026-03-26 13:00:25 -05:00
|
|
|
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget";
|
2026-03-13 16:22:34 -05:00
|
|
|
type ProjectPluginTab = `plugin:${string}`;
|
|
|
|
|
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
|
|
|
|
|
|
|
|
|
function isProjectPluginTab(value: string | null): value is ProjectPluginTab {
|
|
|
|
|
return typeof value === "string" && value.startsWith("plugin:");
|
|
|
|
|
}
|
2026-02-23 09:56:31 -06:00
|
|
|
|
|
|
|
|
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
|
2026-03-02 16:44:03 -06:00
|
|
|
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";
|
2026-03-10 09:08:20 -05:00
|
|
|
if (tab === "configuration") return "configuration";
|
2026-03-16 08:12:38 -05:00
|
|
|
if (tab === "budget") return "budget";
|
2026-03-02 16:44:03 -06:00
|
|
|
if (tab === "issues") return "list";
|
2026-03-26 13:00:25 -05:00
|
|
|
if (tab === "workspaces") return "workspaces";
|
2026-02-23 09:56:31 -06:00
|
|
|
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 })}
|
|
|
|
|
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)}
|
2026-03-02 16:44:03 -06:00
|
|
|
className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
|
2026-02-23 09:56:31 -06:00
|
|
|
style={{ backgroundColor: currentColor }}
|
|
|
|
|
aria-label="Change project color"
|
|
|
|
|
/>
|
|
|
|
|
{open && (
|
2026-02-23 14:41:21 -06:00
|
|
|
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
|
2026-02-23 09:56:31 -06:00
|
|
|
<div className="grid grid-cols-5 gap-1.5">
|
|
|
|
|
{PROJECT_COLORS.map((color) => (
|
|
|
|
|
<button
|
|
|
|
|
key={color}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onSelect(color);
|
|
|
|
|
setOpen(false);
|
|
|
|
|
}}
|
2026-03-02 16:44:03 -06:00
|
|
|
className={`h-6 w-6 rounded-md cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
|
2026-02-23 09:56:31 -06:00
|
|
|
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 ── */
|
|
|
|
|
|
2026-03-02 16:44:03 -06:00
|
|
|
function ProjectIssuesList({ projectId, companyId }: { projectId: string; companyId: string }) {
|
2026-02-23 09:56:31 -06:00
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
|
|
|
|
|
const { data: agents } = useQuery({
|
2026-03-02 16:44:03 -06:00
|
|
|
queryKey: queryKeys.agents.list(companyId),
|
|
|
|
|
queryFn: () => agentsApi.list(companyId),
|
|
|
|
|
enabled: !!companyId,
|
2026-02-23 09:56:31 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { data: liveRuns } = useQuery({
|
2026-03-02 16:44:03 -06:00
|
|
|
queryKey: queryKeys.liveRuns(companyId),
|
|
|
|
|
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
|
|
|
|
enabled: !!companyId,
|
2026-02-23 09:56:31 -06:00
|
|
|
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({
|
2026-03-02 16:44:03 -06:00
|
|
|
queryKey: queryKeys.issues.listByProject(companyId, projectId),
|
|
|
|
|
queryFn: () => issuesApi.list(companyId, { projectId }),
|
|
|
|
|
enabled: !!companyId,
|
2026-02-23 09:56:31 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const updateIssue = useMutation({
|
|
|
|
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
|
|
|
|
issuesApi.update(id, data),
|
|
|
|
|
onSuccess: () => {
|
2026-03-02 16:44:03 -06:00
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
2026-02-23 09:56:31 -06:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 })}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 13:00:25 -05:00
|
|
|
function ProjectWorkspacesContent({
|
|
|
|
|
summaries,
|
|
|
|
|
}: {
|
|
|
|
|
summaries: ReturnType<typeof buildProjectWorkspaceSummaries>;
|
|
|
|
|
}) {
|
|
|
|
|
if (summaries.length === 0) {
|
|
|
|
|
return <p className="text-sm text-muted-foreground">No non-default workspace activity yet.</p>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
|
|
|
|
{summaries.map((summary) => {
|
|
|
|
|
const visibleIssues = summary.issues.slice(0, 3);
|
|
|
|
|
const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={summary.key}
|
|
|
|
|
className="border-b border-border px-4 py-3 last:border-b-0"
|
|
|
|
|
>
|
2026-03-26 16:17:33 -05:00
|
|
|
<div className="grid gap-4 md:grid-cols-[minmax(0,18rem)_minmax(0,1fr)_auto] md:items-start">
|
|
|
|
|
<div className="min-w-0">
|
2026-03-26 13:00:25 -05:00
|
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
|
|
|
{summary.executionWorkspaceId ? (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/execution-workspaces/${summary.executionWorkspaceId}`}
|
|
|
|
|
className="truncate text-sm font-medium hover:underline"
|
|
|
|
|
>
|
|
|
|
|
{summary.workspaceName}
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="truncate text-sm font-medium">{summary.workspaceName}</div>
|
|
|
|
|
)}
|
|
|
|
|
<span className="inline-flex items-center rounded-full border border-border bg-background px-2 py-0.5 text-[11px] text-muted-foreground">
|
|
|
|
|
{summary.kind === "execution_workspace" ? "Isolated workspace" : "Project workspace"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
|
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
|
|
<GitBranch className="h-3.5 w-3.5" />
|
|
|
|
|
<span className="font-mono">{summary.branchName ?? "No branch info"}</span>
|
|
|
|
|
</span>
|
|
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
|
|
<Rows3 className="h-3.5 w-3.5" />
|
|
|
|
|
{summary.issues.length} linked {summary.issues.length === 1 ? "issue" : "issues"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-03-26 16:17:33 -05:00
|
|
|
</div>
|
2026-03-26 13:00:25 -05:00
|
|
|
|
2026-03-26 16:17:33 -05:00
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="mb-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
|
|
|
Issues
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
2026-03-26 13:00:25 -05:00
|
|
|
{visibleIssues.map((issue) => (
|
|
|
|
|
<Link
|
|
|
|
|
key={issue.id}
|
|
|
|
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
|
|
|
|
className="inline-flex max-w-full items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs transition-colors hover:bg-accent"
|
|
|
|
|
>
|
|
|
|
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
|
|
|
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="truncate">{issue.title}</span>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
{hiddenIssueCount > 0 ? (
|
|
|
|
|
<span className="inline-flex items-center rounded-md border border-dashed border-border px-2.5 py-1.5 text-xs text-muted-foreground">
|
|
|
|
|
... and {hiddenIssueCount} more
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-26 16:17:33 -05:00
|
|
|
<div className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground md:justify-self-end">
|
2026-03-26 13:00:25 -05:00
|
|
|
<Clock3 className="h-3.5 w-3.5" />
|
|
|
|
|
{timeAgo(summary.lastUpdatedAt)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 09:56:31 -06:00
|
|
|
/* ── Main project page ── */
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
|
|
|
|
|
export function ProjectDetail() {
|
2026-03-02 16:44:03 -06:00
|
|
|
const { companyPrefix, projectId, filter } = useParams<{
|
|
|
|
|
companyPrefix?: string;
|
|
|
|
|
projectId: string;
|
|
|
|
|
filter?: string;
|
|
|
|
|
}>();
|
|
|
|
|
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
2026-03-10 09:08:20 -05:00
|
|
|
const { closePanel } = usePanel();
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
2026-03-16 07:59:19 -05:00
|
|
|
const { pushToast } = useToast();
|
2026-02-20 10:32:32 -06:00
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const navigate = useNavigate();
|
2026-02-23 09:56:31 -06:00
|
|
|
const location = useLocation();
|
2026-03-10 10:04:08 -05:00
|
|
|
const [fieldSaveStates, setFieldSaveStates] = useState<Partial<Record<ProjectConfigFieldKey, ProjectFieldSaveState>>>({});
|
|
|
|
|
const fieldSaveRequestIds = useRef<Partial<Record<ProjectConfigFieldKey, number>>>({});
|
|
|
|
|
const fieldSaveTimers = useRef<Partial<Record<ProjectConfigFieldKey, ReturnType<typeof setTimeout>>>>({});
|
2026-03-02 16:44:03 -06:00
|
|
|
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));
|
2026-03-13 16:22:34 -05:00
|
|
|
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;
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
|
2026-02-17 12:24:48 -06:00
|
|
|
const { data: project, isLoading, error } = useQuery({
|
2026-03-02 16:44:03 -06:00
|
|
|
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
|
|
|
|
queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId),
|
|
|
|
|
enabled: canFetchProject,
|
2026-02-17 12:24:48 -06:00
|
|
|
});
|
2026-03-02 16:44:03 -06:00
|
|
|
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
|
|
|
|
const projectLookupRef = project?.id ?? routeProjectRef;
|
|
|
|
|
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
2026-03-26 13:00:25 -05:00
|
|
|
const experimentalSettingsQuery = useQuery({
|
|
|
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
|
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
|
|
|
});
|
2026-03-13 16:22:34 -05:00
|
|
|
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;
|
2026-03-26 13:00:25 -05:00
|
|
|
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;
|
2026-03-02 16:44:03 -06:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
|
|
|
|
setSelectedCompanyId(project.companyId, { source: "route_sync" });
|
|
|
|
|
}, [project?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
|
2026-02-20 10:32:32 -06:00
|
|
|
const invalidateProject = () => {
|
2026-03-02 16:44:03 -06:00
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
|
|
|
|
|
if (resolvedCompanyId) {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) });
|
2026-02-20 10:32:32 -06:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateProject = useMutation({
|
2026-03-02 16:44:03 -06:00
|
|
|
mutationFn: (data: Record<string, unknown>) =>
|
|
|
|
|
projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId),
|
2026-02-20 10:32:32 -06:00
|
|
|
onSuccess: invalidateProject,
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-14 17:47:53 -05:00
|
|
|
const archiveProject = useMutation({
|
|
|
|
|
mutationFn: (archived: boolean) =>
|
|
|
|
|
projectsApi.update(
|
|
|
|
|
projectLookupRef,
|
|
|
|
|
{ archivedAt: archived ? new Date().toISOString() : null },
|
|
|
|
|
resolvedCompanyId ?? lookupCompanyId,
|
|
|
|
|
),
|
2026-03-16 07:59:19 -05:00
|
|
|
onSuccess: (updatedProject, archived) => {
|
2026-03-14 17:47:53 -05:00
|
|
|
invalidateProject();
|
2026-03-16 07:59:19 -05:00
|
|
|
const name = updatedProject?.name ?? project?.name ?? "Project";
|
2026-03-14 17:47:53 -05:00
|
|
|
if (archived) {
|
2026-03-16 07:59:19 -05:00
|
|
|
pushToast({ title: `"${name}" has been archived`, tone: "success" });
|
2026-03-16 18:46:26 -05:00
|
|
|
navigate("/dashboard");
|
|
|
|
|
} else {
|
2026-03-16 07:59:19 -05:00
|
|
|
pushToast({ title: `"${name}" has been unarchived`, tone: "success" });
|
2026-03-14 17:47:53 -05:00
|
|
|
}
|
|
|
|
|
},
|
2026-03-16 18:46:26 -05:00
|
|
|
onError: (_, archived) => {
|
|
|
|
|
pushToast({
|
|
|
|
|
title: archived ? "Failed to archive project" : "Failed to unarchive project",
|
|
|
|
|
tone: "error",
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-03-14 17:47:53 -05:00
|
|
|
});
|
|
|
|
|
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
const uploadImage = useMutation({
|
|
|
|
|
mutationFn: async (file: File) => {
|
2026-03-02 16:44:03 -06:00
|
|
|
if (!resolvedCompanyId) throw new Error("No company selected");
|
|
|
|
|
return assetsApi.uploadImage(resolvedCompanyId, file, `projects/${projectLookupRef || "draft"}`);
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-14 22:00:12 -05:00
|
|
|
const { data: budgetOverview } = useQuery({
|
|
|
|
|
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
|
|
|
|
|
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
|
|
|
|
|
enabled: !!resolvedCompanyId,
|
|
|
|
|
refetchInterval: 30_000,
|
|
|
|
|
staleTime: 5_000,
|
|
|
|
|
});
|
|
|
|
|
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
useEffect(() => {
|
|
|
|
|
setBreadcrumbs([
|
|
|
|
|
{ label: "Projects", href: "/projects" },
|
2026-03-02 16:44:03 -06:00
|
|
|
{ label: project?.name ?? routeProjectRef ?? "Project" },
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
]);
|
2026-03-02 16:44:03 -06:00
|
|
|
}, [setBreadcrumbs, project, routeProjectRef]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!project) return;
|
|
|
|
|
if (routeProjectRef === canonicalProjectRef) return;
|
2026-03-13 16:22:34 -05:00
|
|
|
if (isProjectPluginTab(activeTab)) {
|
|
|
|
|
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-02 16:44:03 -06:00
|
|
|
if (activeTab === "overview") {
|
|
|
|
|
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-10 09:08:20 -05:00
|
|
|
if (activeTab === "configuration") {
|
|
|
|
|
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-16 08:12:38 -05:00
|
|
|
if (activeTab === "budget") {
|
|
|
|
|
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-26 13:00:25 -05:00
|
|
|
if (activeTab === "workspaces") {
|
|
|
|
|
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-02 16:44:03 -06:00
|
|
|
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]);
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-10 09:08:20 -05:00
|
|
|
closePanel();
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
return () => closePanel();
|
2026-03-10 09:08:20 -05:00
|
|
|
}, [closePanel]);
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
|
2026-03-10 10:04:08 -05:00
|
|
|
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]);
|
|
|
|
|
|
2026-03-14 22:00:12 -05:00
|
|
|
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) });
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-13 16:22:34 -05:00
|
|
|
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
|
|
|
|
|
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 13:00:25 -05:00
|
|
|
if (activeTab === "workspaces" && workspaceTabDecisionLoaded && !showWorkspacesTab) {
|
|
|
|
|
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 07:46:46 -05:00
|
|
|
// Redirect bare /projects/:id to cached tab or default /issues
|
2026-03-02 16:44:03 -06:00
|
|
|
if (routeProjectRef && activeTab === null) {
|
2026-03-16 07:46:46 -05:00
|
|
|
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 />;
|
|
|
|
|
}
|
2026-03-17 09:21:44 -05:00
|
|
|
if (cachedTab === "budget") {
|
|
|
|
|
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
|
|
|
|
}
|
2026-03-26 13:00:25 -05:00
|
|
|
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
|
|
|
|
|
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
|
|
|
|
|
}
|
|
|
|
|
if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) {
|
|
|
|
|
return <PageSkeleton variant="detail" />;
|
|
|
|
|
}
|
2026-03-16 07:46:46 -05:00
|
|
|
if (isProjectPluginTab(cachedTab)) {
|
|
|
|
|
return <Navigate to={`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(cachedTab)}`} replace />;
|
|
|
|
|
}
|
2026-03-02 16:44:03 -06:00
|
|
|
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
2026-02-23 09:56:31 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:44:03 -06:00
|
|
|
if (isLoading) return <PageSkeleton variant="detail" />;
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
|
|
|
|
if (!project) return null;
|
|
|
|
|
|
2026-02-23 09:56:31 -06:00
|
|
|
const handleTabChange = (tab: ProjectTab) => {
|
2026-03-16 07:46:46 -05:00
|
|
|
// Cache the active tab per project
|
|
|
|
|
if (project?.id) {
|
|
|
|
|
try { localStorage.setItem(`paperclip:project-tab:${project.id}`, tab); } catch {}
|
|
|
|
|
}
|
2026-03-13 16:22:34 -05:00
|
|
|
if (isProjectPluginTab(tab)) {
|
|
|
|
|
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-23 09:56:31 -06:00
|
|
|
if (tab === "overview") {
|
2026-03-02 16:44:03 -06:00
|
|
|
navigate(`/projects/${canonicalProjectRef}/overview`);
|
2026-03-26 13:00:25 -05:00
|
|
|
} else if (tab === "workspaces") {
|
|
|
|
|
navigate(`/projects/${canonicalProjectRef}/workspaces`);
|
2026-03-16 08:12:38 -05:00
|
|
|
} else if (tab === "budget") {
|
|
|
|
|
navigate(`/projects/${canonicalProjectRef}/budget`);
|
2026-03-10 09:08:20 -05:00
|
|
|
} else if (tab === "configuration") {
|
|
|
|
|
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
2026-02-23 09:56:31 -06:00
|
|
|
} else {
|
2026-03-02 16:44:03 -06:00
|
|
|
navigate(`/projects/${canonicalProjectRef}/issues`);
|
2026-02-23 09:56:31 -06:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
2026-02-23 14:41:21 -06:00
|
|
|
<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>
|
2026-03-16 08:12:38 -05:00
|
|
|
<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>
|
2026-02-23 09:56:31 -06:00
|
|
|
</div>
|
2026-02-20 10:32:32 -06:00
|
|
|
|
2026-03-13 23:03:51 -05:00
|
|
|
<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"
|
|
|
|
|
/>
|
|
|
|
|
|
2026-03-10 09:08:20 -05:00
|
|
|
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
|
|
|
|
|
<PageTabBar
|
|
|
|
|
items={[
|
2026-03-16 07:46:46 -05:00
|
|
|
{ value: "list", label: "Issues" },
|
2026-03-10 09:08:20 -05:00
|
|
|
{ value: "overview", label: "Overview" },
|
2026-03-26 13:00:25 -05:00
|
|
|
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
|
2026-03-10 09:08:20 -05:00
|
|
|
{ value: "configuration", label: "Configuration" },
|
2026-03-16 08:12:38 -05:00
|
|
|
{ value: "budget", label: "Budget" },
|
2026-03-13 16:22:34 -05:00
|
|
|
...pluginTabItems.map((item) => ({
|
|
|
|
|
value: item.value,
|
|
|
|
|
label: item.label,
|
|
|
|
|
})),
|
2026-03-10 09:08:20 -05:00
|
|
|
]}
|
2026-03-10 10:04:08 -05:00
|
|
|
align="start"
|
2026-03-10 09:08:20 -05:00
|
|
|
value={activeTab ?? "list"}
|
|
|
|
|
onValueChange={(value) => handleTabChange(value as ProjectTab)}
|
|
|
|
|
/>
|
|
|
|
|
</Tabs>
|
2026-02-23 09:56:31 -06:00
|
|
|
|
|
|
|
|
{activeTab === "overview" && (
|
|
|
|
|
<OverviewContent
|
|
|
|
|
project={project}
|
|
|
|
|
onUpdate={(data) => updateProject.mutate(data)}
|
Add MarkdownEditor component, asset image upload, and rich description editing
Introduce MarkdownEditor built on @mdxeditor/editor with headings,
lists, links, quotes, image upload with drag-and-drop, and themed CSS
integration. Add asset image upload API (routes, service, storage) and
wire image upload into InlineEditor multiline mode, NewIssueDialog,
NewProjectDialog, GoalDetail, IssueDetail, and ProjectDetail
description fields. Tighten prompt template editor styling.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:50:45 -06:00
|
|
|
imageUploadHandler={async (file) => {
|
|
|
|
|
const asset = await uploadImage.mutateAsync(file);
|
|
|
|
|
return asset.contentPath;
|
|
|
|
|
}}
|
2026-02-20 10:32:32 -06:00
|
|
|
/>
|
2026-02-23 09:56:31 -06:00
|
|
|
)}
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
|
2026-03-02 16:44:03 -06:00
|
|
|
{activeTab === "list" && project?.id && resolvedCompanyId && (
|
|
|
|
|
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
2026-02-23 09:56:31 -06:00
|
|
|
)}
|
2026-03-05 18:57:48 -06:00
|
|
|
|
2026-03-26 13:00:25 -05:00
|
|
|
{activeTab === "workspaces" ? (
|
|
|
|
|
workspaceTabDecisionLoaded ? (
|
|
|
|
|
workspaceTabError ? (
|
|
|
|
|
<p className="text-sm text-destructive">{workspaceTabError.message}</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ProjectWorkspacesContent summaries={workspaceSummaries} />
|
|
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-sm text-muted-foreground">Loading workspaces...</p>
|
|
|
|
|
)
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-03-10 09:08:20 -05:00
|
|
|
{activeTab === "configuration" && (
|
2026-03-10 10:04:08 -05:00
|
|
|
<div className="max-w-4xl">
|
|
|
|
|
<ProjectProperties
|
|
|
|
|
project={project}
|
|
|
|
|
onUpdate={(data) => updateProject.mutate(data)}
|
|
|
|
|
onFieldUpdate={updateProjectField}
|
|
|
|
|
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
|
2026-03-14 17:47:53 -05:00
|
|
|
onArchive={(archived) => archiveProject.mutate(archived)}
|
|
|
|
|
archivePending={archiveProject.isPending}
|
2026-03-10 10:04:08 -05:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-03-10 09:08:20 -05:00
|
|
|
)}
|
2026-03-13 16:22:34 -05:00
|
|
|
|
2026-03-16 08:12:38 -05:00
|
|
|
{activeTab === "budget" && resolvedCompanyId ? (
|
|
|
|
|
<div className="max-w-3xl">
|
|
|
|
|
<BudgetPolicyCard
|
|
|
|
|
summary={projectBudgetSummary}
|
|
|
|
|
variant="plain"
|
|
|
|
|
isSaving={budgetMutation.isPending}
|
|
|
|
|
onSave={(amount) => budgetMutation.mutate(amount)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-03-13 16:22:34 -05:00
|
|
|
{activePluginTab && (
|
|
|
|
|
<PluginSlotMount
|
|
|
|
|
slot={activePluginTab.slot}
|
|
|
|
|
context={{
|
|
|
|
|
companyId: resolvedCompanyId,
|
|
|
|
|
companyPrefix: companyPrefix ?? null,
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
projectRef: canonicalProjectRef,
|
|
|
|
|
entityId: project.id,
|
|
|
|
|
entityType: "project",
|
|
|
|
|
}}
|
|
|
|
|
missingBehavior="placeholder"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
Add detail pages, property panels, and restyle list pages
New pages: AgentDetail, GoalDetail, IssueDetail, ProjectDetail, Inbox,
MyIssues. New feature components: AgentProperties, GoalProperties,
IssueProperties, ProjectProperties, GoalTree, NewIssueDialog. Add
heartbeats API client. Restyle all list pages (Agents, Issues, Goals,
Projects, Dashboard, Costs, Activity, Org) with EntityRow, FilterBar,
and improved layouts. Add routing for detail views.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:57:06 -06:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|