diff --git a/ui/src/adapters/local-workspace-runtime-fields.tsx b/ui/src/adapters/local-workspace-runtime-fields.tsx index e1e32af4..c5043ad0 100644 --- a/ui/src/adapters/local-workspace-runtime-fields.tsx +++ b/ui/src/adapters/local-workspace-runtime-fields.tsx @@ -1,144 +1,5 @@ -import { useState } from "react"; import type { AdapterConfigFieldsProps } from "./types"; -import { CollapsibleSection, DraftInput, Field, help } from "../components/agent-config-primitives"; -import { RuntimeServicesJsonField } from "./runtime-json-fields"; -const inputClass = - "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; - -function asRecord(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function asString(value: unknown): string { - return typeof value === "string" ? value : ""; -} - -function readWorkspaceStrategy(config: Record) { - const strategy = asRecord(config.workspaceStrategy); - const type = asString(strategy.type) || "project_primary"; - return { - type, - baseRef: asString(strategy.baseRef), - branchTemplate: asString(strategy.branchTemplate), - worktreeParentDir: asString(strategy.worktreeParentDir), - }; -} - -function buildWorkspaceStrategyPatch(input: { - type: string; - baseRef?: string; - branchTemplate?: string; - worktreeParentDir?: string; -}) { - if (input.type !== "git_worktree") return undefined; - return { - type: "git_worktree", - ...(input.baseRef ? { baseRef: input.baseRef } : {}), - ...(input.branchTemplate ? { branchTemplate: input.branchTemplate } : {}), - ...(input.worktreeParentDir ? { worktreeParentDir: input.worktreeParentDir } : {}), - }; -} - -export function LocalWorkspaceRuntimeFields({ - isCreate, - values, - set, - config, - mark, -}: AdapterConfigFieldsProps) { - const [open, setOpen] = useState(false); - const existing = readWorkspaceStrategy(config); - const strategyType = isCreate ? values!.workspaceStrategyType ?? "project_primary" : existing.type; - const updateEditWorkspaceStrategy = (patch: Partial) => { - const next = { - ...existing, - ...patch, - }; - mark( - "adapterConfig", - "workspaceStrategy", - buildWorkspaceStrategyPatch(next), - ); - }; - return ( - setOpen((value) => !value)} - > -
- - - - - {strategyType === "git_worktree" && ( - <> - - - isCreate - ? set!({ workspaceBaseRef: v }) - : updateEditWorkspaceStrategy({ baseRef: v || "" }) - } - immediate - className={inputClass} - placeholder="origin/main" - /> - - - - isCreate - ? set!({ workspaceBranchTemplate: v }) - : updateEditWorkspaceStrategy({ branchTemplate: v || "" }) - } - immediate - className={inputClass} - placeholder="{{issue.identifier}}-{{slug}}" - /> - - - - isCreate - ? set!({ worktreeParentDir: v }) - : updateEditWorkspaceStrategy({ worktreeParentDir: v || "" }) - } - immediate - className={inputClass} - placeholder=".paperclip/worktrees" - /> - - - )} - -
-
- ); +export function LocalWorkspaceRuntimeFields(_props: AdapterConfigFieldsProps) { + return null; } diff --git a/ui/src/components/PageTabBar.tsx b/ui/src/components/PageTabBar.tsx index f0b6c9d2..a1be3f2d 100644 --- a/ui/src/components/PageTabBar.tsx +++ b/ui/src/components/PageTabBar.tsx @@ -11,9 +11,10 @@ interface PageTabBarProps { items: PageTabItem[]; value?: string; onValueChange?: (value: string) => void; + align?: "center" | "start"; } -export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) { +export function PageTabBar({ items, value, onValueChange, align = "center" }: PageTabBarProps) { const { isMobile } = useSidebar(); if (isMobile && value !== undefined && onValueChange) { @@ -33,7 +34,7 @@ export function PageTabBar({ items, value, onValueChange }: PageTabBarProps) { } return ( - + {items.map((item) => ( {item.label} diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index 28ead905..731304f4 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -13,9 +13,10 @@ import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { ExternalLink, Github, Plus, Trash2, X } from "lucide-react"; +import { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react"; import { ChoosePathButton } from "./PathInstructionsModal"; -import { CollapsibleSection, DraftInput } from "./agent-config-primitives"; +import { DraftInput } from "./agent-config-primitives"; +import { InlineEditor } from "./InlineEditor"; const PROJECT_STATUSES = [ { value: "backlog", label: "Backlog" }, @@ -28,15 +29,84 @@ const PROJECT_STATUSES = [ interface ProjectPropertiesProps { project: Project; onUpdate?: (data: Record) => void; + onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record) => void; + getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState; } +export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error"; +export type ProjectConfigFieldKey = + | "name" + | "description" + | "status" + | "goals" + | "execution_workspace_enabled" + | "execution_workspace_default_mode" + | "execution_workspace_base_ref" + | "execution_workspace_branch_template" + | "execution_workspace_worktree_parent_dir"; + const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; -function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { +function SaveIndicator({ state }: { state: ProjectFieldSaveState }) { + if (state === "saving") { + return ( + + + Saving + + ); + } + if (state === "saved") { + return ( + + + Saved + + ); + } + if (state === "error") { + return ( + + + Failed + + ); + } + return null; +} + +function FieldLabel({ + label, + state, +}: { + label: string; + state: ProjectFieldSaveState; +}) { return ( -
- {label} -
{children}
+
+ {label} + +
+ ); +} + +function PropertyRow({ + label, + children, + alignStart = false, + valueClassName = "", +}: { + label: React.ReactNode; + children: React.ReactNode; + alignStart?: boolean; + valueClassName?: string; +}) { + return ( +
+
{label}
+
+ {children} +
); } @@ -77,7 +147,7 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: ( ); } -export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) { +export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const [goalOpen, setGoalOpen] = useState(false); @@ -87,6 +157,15 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState(""); const [workspaceError, setWorkspaceError] = useState(null); + const commitField = (field: ProjectConfigFieldKey, data: Record) => { + if (onFieldUpdate) { + onFieldUpdate(field, data); + return; + } + onUpdate?.(data); + }; + const fieldState = (field: ProjectConfigFieldKey): ProjectFieldSaveState => getFieldSaveState?.(field) ?? "idle"; + const { data: allGoals } = useQuery({ queryKey: queryKeys.goals.list(selectedCompanyId!), queryFn: () => goalsApi.list(selectedCompanyId!), @@ -148,19 +227,19 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) }); const removeGoal = (goalId: string) => { - if (!onUpdate) return; - onUpdate({ goalIds: linkedGoalIds.filter((id) => id !== goalId) }); + if (!onUpdate && !onFieldUpdate) return; + commitField("goals", { goalIds: linkedGoalIds.filter((id) => id !== goalId) }); }; const addGoal = (goalId: string) => { - if (!onUpdate || linkedGoalIds.includes(goalId)) return; - onUpdate({ goalIds: [...linkedGoalIds, goalId] }); + if ((!onUpdate && !onFieldUpdate) || linkedGoalIds.includes(goalId)) return; + commitField("goals", { goalIds: [...linkedGoalIds, goalId] }); setGoalOpen(false); }; const updateExecutionWorkspacePolicy = (patch: Record) => { - if (!onUpdate) return; - onUpdate({ + if (!onUpdate && !onFieldUpdate) return; + return { executionWorkspacePolicy: { enabled: executionWorkspacesEnabled, defaultMode: executionWorkspaceDefaultMode, @@ -168,7 +247,7 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) ...executionWorkspacePolicy, ...patch, }, - }); + }; }; const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); @@ -279,13 +358,46 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) }; return ( -
-
- - {onUpdate ? ( +
+
+ }> + {onUpdate || onFieldUpdate ? ( + commitField("name", { name })} + immediate + className="w-full rounded border border-border bg-transparent px-2 py-1 text-sm outline-none" + placeholder="Project name" + /> + ) : ( + {project.name} + )} + + } + alignStart + valueClassName="space-y-0.5" + > + {onUpdate || onFieldUpdate ? ( + commitField("description", { description })} + as="p" + className="text-sm text-muted-foreground" + placeholder="Add a description..." + multiline + /> + ) : ( +

+ {project.description?.trim() || "No description"} +

+ )} +
+ }> + {onUpdate || onFieldUpdate ? ( onUpdate({ status })} + onChange={(status) => commitField("status", { status })} /> ) : ( @@ -296,238 +408,87 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) {project.leadAgentId.slice(0, 8)} )} -
-
- Goals -
- {linkedGoals.length === 0 ? ( - None - ) : ( -
- {linkedGoals.map((goal) => ( - } + alignStart + valueClassName="space-y-2" + > + {linkedGoals.length === 0 ? ( + None + ) : ( +
+ {linkedGoals.map((goal) => ( + + + {goal.title} + + {(onUpdate || onFieldUpdate) && ( + - )} - - ))} -
- )} - {onUpdate && ( - - - - - - {availableGoals.length === 0 ? ( -
- All goals linked. -
- ) : ( - availableGoals.map((goal) => ( - - )) - )} -
-
- )} + + + )} +
+ ))}
-
-
+ )} + {(onUpdate || onFieldUpdate) && ( + + + + + + {availableGoals.length === 0 ? ( +
+ All goals linked. +
+ ) : ( + availableGoals.map((goal) => ( + + )) + )} +
+
+ )} + + }> + {formatDate(project.createdAt)} + + }> + {formatDate(project.updatedAt)} + {project.targetDate && ( - + }> {formatDate(project.targetDate)} )}
- + -
-
-
- Execution Workspaces - - - - - - Project-owned defaults for isolated issue checkouts and execution workspace behavior. - - -
-
-
-
-
Enable isolated issue checkouts
-
- Let issues choose between the project’s primary checkout and an isolated execution workspace. -
-
- {onUpdate ? ( - - ) : ( - - {executionWorkspacesEnabled ? "Enabled" : "Disabled"} - - )} -
- - {executionWorkspacesEnabled && ( - <> -
-
-
New issues default to isolated checkout
-
- If disabled, new issues stay on the project’s primary checkout unless someone opts in. -
-
- -
- - setExecutionWorkspaceAdvancedOpen((open) => !open)} - > -
-
- Host-managed implementation: Git worktree -
-
-
- -
- - updateExecutionWorkspacePolicy({ - workspaceStrategy: { - ...executionWorkspaceStrategy, - type: "git_worktree", - baseRef: value || null, - }, - })} - immediate - className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" - placeholder="origin/main" - /> -
-
-
- -
- - updateExecutionWorkspacePolicy({ - workspaceStrategy: { - ...executionWorkspaceStrategy, - type: "git_worktree", - branchTemplate: value || null, - }, - })} - immediate - className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" - placeholder="{{issue.identifier}}-{{slug}}" - /> -
-
-
- -
- - updateExecutionWorkspacePolicy({ - workspaceStrategy: { - ...executionWorkspaceStrategy, - type: "git_worktree", - worktreeParentDir: value || null, - }, - })} - immediate - className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" - placeholder=".paperclip/worktrees" - /> -
-

- Runtime services stay under Paperclip control and are not configured here yet. -

-
-
- - )} -
-
- - - -
+
+
Workspaces @@ -744,14 +705,196 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps) )}
- + + +
+
+ Execution Workspaces + + + + + + Project-owned defaults for isolated issue checkouts and execution workspace behavior. + + +
+
+
+
+
+ Enable isolated issue checkouts + +
+
+ Let issues choose between the project’s primary checkout and an isolated execution workspace. +
+
+ {onUpdate || onFieldUpdate ? ( + + ) : ( + + {executionWorkspacesEnabled ? "Enabled" : "Disabled"} + + )} +
+ + {executionWorkspacesEnabled && ( + <> +
+
+
+ New issues default to isolated checkout + +
+
+ If disabled, new issues stay on the project’s primary checkout unless someone opts in. +
+
+ +
+ +
+ +
+ + {executionWorkspaceAdvancedOpen && ( +
+
+ Host-managed implementation: Git worktree +
+
+
+ +
+ + commitField("execution_workspace_base_ref", { + ...updateExecutionWorkspacePolicy({ + workspaceStrategy: { + ...executionWorkspaceStrategy, + type: "git_worktree", + baseRef: value || null, + }, + })!, + })} + immediate + className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" + placeholder="origin/main" + /> +
+
+
+ +
+ + commitField("execution_workspace_branch_template", { + ...updateExecutionWorkspacePolicy({ + workspaceStrategy: { + ...executionWorkspaceStrategy, + type: "git_worktree", + branchTemplate: value || null, + }, + })!, + })} + immediate + className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" + placeholder="{{issue.identifier}}-{{slug}}" + /> +
+
+
+ +
+ + commitField("execution_workspace_worktree_parent_dir", { + ...updateExecutionWorkspacePolicy({ + workspaceStrategy: { + ...executionWorkspaceStrategy, + type: "git_worktree", + worktreeParentDir: value || null, + }, + })!, + })} + immediate + className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none" + placeholder=".paperclip/worktrees" + /> +
+

+ Runtime services stay under Paperclip control and are not configured here yet. +

+
+ )} + + )} +
+
- - {formatDate(project.createdAt)} - - - {formatDate(project.updatedAt)} -
); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 5cd7db7a..3cbb6394 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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({

Costs

- - {/* Configuration Summary */} - -
- ); -} - -/* 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; - const promptText = typeof config?.promptTemplate === "string" ? config.promptTemplate : ""; - - return ( -
-

Configuration

-
-
-

Agent Details

-
- - {adapterLabels[agent.adapterType] ?? agent.adapterType} - {String(config?.model ?? "") !== "" && ( - - ({String(config.model)}) - - )} - - - {(agent.runtimeConfig as Record)?.heartbeat - ? (() => { - const hb = (agent.runtimeConfig as Record).heartbeat as Record; - if (!hb.enabled) return Disabled; - 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 ( - - Every {intervalLabel} - {maxConcurrentRuns > 1 ? ` (max ${maxConcurrentRuns} concurrent)` : ""} - - ); - })() - : Not configured - } - - - {agent.lastHeartbeatAt - ? {relativeTime(agent.lastHeartbeatAt)} - : Never - } - - - {reportsToAgent ? ( - - - - ) : ( - Nobody (top-level) - )} - -
- {directReports.length > 0 && ( -
- Direct reports -
- {directReports.map((r) => ( - - - - - {r.name} - ({roleLabels[r.role] ?? r.role}) - - ))} -
-
- )} - {agent.capabilities && ( -
- Capabilities -

{agent.capabilities}

-
- )} -
- {promptText && ( -
-

Prompt Template

-
{promptText}
-
- )} -
); } diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index b1dcfd39..84311852 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -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>>({}); + const fieldSaveRequestIds = useRef>>({}); + const fieldSaveTimers = useRef>>>({}); 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) => { + 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 ; @@ -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" && ( - updateProject.mutate(data)} /> +
+ updateProject.mutate(data)} + onFieldUpdate={updateProjectField} + getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"} + /> +
)}
);