Refine project and agent configuration UI

This commit is contained in:
Dotta
2026-03-10 10:04:08 -05:00
parent 6186eba098
commit e94ce47ba5
5 changed files with 454 additions and 514 deletions

View File

@@ -642,8 +642,6 @@ export function AgentDetail() {
runs={heartbeats ?? []}
assignedIssues={assignedIssues}
runtimeState={runtimeState}
reportsToAgent={reportsToAgent ?? null}
directReports={directReports}
agentId={agent.id}
agentRouteId={canonicalAgentRef}
/>
@@ -763,8 +761,6 @@ function AgentOverview({
runs,
assignedIssues,
runtimeState,
reportsToAgent,
directReports,
agentId,
agentRouteId,
}: {
@@ -772,8 +768,6 @@ function AgentOverview({
runs: HeartbeatRun[];
assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
runtimeState?: AgentRuntimeState;
reportsToAgent: Agent | null;
directReports: Agent[];
agentId: string;
agentRouteId: string;
}) {
@@ -833,119 +827,6 @@ function AgentOverview({
<h3 className="text-sm font-medium">Costs</h3>
<CostsSection runtimeState={runtimeState} runs={runs} />
</div>
{/* Configuration Summary */}
<ConfigSummary
agent={agent}
reportsToAgent={reportsToAgent}
directReports={directReports}
/>
</div>
);
}
/* Chart components imported from ../components/ActivityCharts */
/* ---- Configuration Summary ---- */
function ConfigSummary({
agent,
reportsToAgent,
directReports,
}: {
agent: Agent;
reportsToAgent: Agent | null;
directReports: Agent[];
}) {
const config = agent.adapterConfig as Record<string, unknown>;
const promptText = typeof config?.promptTemplate === "string" ? config.promptTemplate : "";
return (
<div className="space-y-3">
<h3 className="text-sm font-medium">Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="border border-border rounded-lg p-4 space-y-3">
<h4 className="text-xs text-muted-foreground font-medium">Agent Details</h4>
<div className="space-y-2 text-sm">
<SummaryRow label="Adapter">
<span className="font-mono">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
{String(config?.model ?? "") !== "" && (
<span className="text-muted-foreground ml-1">
({String(config.model)})
</span>
)}
</SummaryRow>
<SummaryRow label="Heartbeat">
{(agent.runtimeConfig as Record<string, unknown>)?.heartbeat
? (() => {
const hb = (agent.runtimeConfig as Record<string, unknown>).heartbeat as Record<string, unknown>;
if (!hb.enabled) return <span className="text-muted-foreground">Disabled</span>;
const sec = Number(hb.intervalSec) || 300;
const maxConcurrentRuns = Math.max(1, Math.floor(Number(hb.maxConcurrentRuns) || 1));
const intervalLabel = sec >= 60 ? `${Math.round(sec / 60)} min` : `${sec}s`;
return (
<span>
Every {intervalLabel}
{maxConcurrentRuns > 1 ? ` (max ${maxConcurrentRuns} concurrent)` : ""}
</span>
);
})()
: <span className="text-muted-foreground">Not configured</span>
}
</SummaryRow>
<SummaryRow label="Last heartbeat">
{agent.lastHeartbeatAt
? <span>{relativeTime(agent.lastHeartbeatAt)}</span>
: <span className="text-muted-foreground">Never</span>
}
</SummaryRow>
<SummaryRow label="Reports to">
{reportsToAgent ? (
<Link
to={`/agents/${agentRouteRef(reportsToAgent)}`}
className="text-blue-600 hover:underline dark:text-blue-400"
>
<Identity name={reportsToAgent.name} size="sm" />
</Link>
) : (
<span className="text-muted-foreground">Nobody (top-level)</span>
)}
</SummaryRow>
</div>
{directReports.length > 0 && (
<div className="pt-1">
<span className="text-xs text-muted-foreground">Direct reports</span>
<div className="mt-1 space-y-1">
{directReports.map((r) => (
<Link
key={r.id}
to={`/agents/${agentRouteRef(r)}`}
className="flex items-center gap-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
>
<span className="relative flex h-2 w-2">
<span className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[r.status] ?? agentStatusDotDefault}`} />
</span>
{r.name}
<span className="text-muted-foreground text-xs">({roleLabels[r.role] ?? r.role})</span>
</Link>
))}
</div>
</div>
)}
{agent.capabilities && (
<div className="pt-1">
<span className="text-xs text-muted-foreground">Capabilities</span>
<p className="text-sm mt-0.5">{agent.capabilities}</p>
</div>
)}
</div>
{promptText && (
<div className="border border-border rounded-lg p-4 space-y-2">
<h4 className="text-xs text-muted-foreground font-medium">Prompt Template</h4>
<pre className="text-xs text-muted-foreground line-clamp-[12] font-mono whitespace-pre-wrap">{promptText}</pre>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useRef } from "react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PROJECT_COLORS, isUuidLike } from "@paperclipai/shared";
@@ -11,7 +11,7 @@ import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties } from "../components/ProjectProperties";
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge";
import { IssuesList } from "../components/IssuesList";
@@ -202,6 +202,9 @@ export function ProjectDetail() {
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;
@@ -282,6 +285,49 @@ export function ProjectDetail() {
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]);
// Redirect bare /projects/:id to /projects/:id/issues
if (routeProjectRef && activeTab === null) {
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
@@ -325,6 +371,7 @@ export function ProjectDetail() {
{ value: "list", label: "List" },
{ value: "configuration", label: "Configuration" },
]}
align="start"
value={activeTab ?? "list"}
onValueChange={(value) => handleTabChange(value as ProjectTab)}
/>
@@ -346,7 +393,14 @@ export function ProjectDetail() {
)}
{activeTab === "configuration" && (
<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />
<div className="max-w-4xl">
<ProjectProperties
project={project}
onUpdate={(data) => updateProject.mutate(data)}
onFieldUpdate={updateProjectField}
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
/>
</div>
)}
</div>
);