Refine project and agent configuration UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user