diff --git a/web/src/components/projects/project-general-form.tsx b/web/src/components/projects/project-general-form.tsx index 45b25856..45b0de6c 100644 --- a/web/src/components/projects/project-general-form.tsx +++ b/web/src/components/projects/project-general-form.tsx @@ -1,16 +1,6 @@ -import { EngineSettingsFields } from '@/components/settings/engine-settings-fields.js'; -import { ModelField } from '@/components/settings/model-field.js'; +import { useProjectUpdate } from '@/components/projects/use-project-update.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select.js'; -import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; interface Project { @@ -35,14 +25,11 @@ function numericFieldDefault(value: number | null | undefined): string { } export function ProjectGeneralForm({ project }: { project: Project }) { - const queryClient = useQueryClient(); - const enginesQuery = useQuery(trpc.agentConfigs.engines.queryOptions()); + const updateMutation = useProjectUpdate(project.id); const [name, setName] = useState(project.name); const [repo, setRepo] = useState(project.repo ?? ''); const [baseBranch, setBaseBranch] = useState(project.baseBranch ?? 'main'); const [branchPrefix, setBranchPrefix] = useState(project.branchPrefix ?? 'feature/'); - const [model, setModel] = useState(project.model ?? ''); - const [maxIterations, setMaxIterations] = useState(numericFieldDefault(project.maxIterations)); const [watchdogTimeoutMs, setWatchdogTimeoutMs] = useState( numericFieldDefault(project.watchdogTimeoutMs), ); @@ -51,25 +38,8 @@ export function ProjectGeneralForm({ project }: { project: Project }) { project.progressIntervalMinutes ?? '', ); const [workItemBudgetUsd, setWorkItemBudgetUsd] = useState(project.workItemBudgetUsd ?? ''); - const [agentEngine, setAgentEngine] = useState(project.agentEngine ?? ''); - const [engineSettings, setEngineSettings] = useState>>( - project.engineSettings ?? {}, - ); const [runLinksEnabled, setRunLinksEnabled] = useState(project.runLinksEnabled ?? false); - const updateMutation = useMutation({ - mutationFn: (data: Record) => - trpcClient.projects.update.mutate({ id: project.id, ...data } as Parameters< - typeof trpcClient.projects.update.mutate - >[0]), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.getById.queryOptions({ id: project.id }).queryKey, - }); - queryClient.invalidateQueries({ queryKey: trpc.projects.listFull.queryOptions().queryKey }); - }, - }); - function handleSubmit(e: React.FormEvent) { e.preventDefault(); updateMutation.mutate({ @@ -77,21 +47,14 @@ export function ProjectGeneralForm({ project }: { project: Project }) { repo: repo || undefined, baseBranch, branchPrefix, - model: model || null, - maxIterations: maxIterations ? Number.parseInt(maxIterations, 10) : null, watchdogTimeoutMs: watchdogTimeoutMs ? Number.parseInt(watchdogTimeoutMs, 10) : null, progressModel: progressModel || null, progressIntervalMinutes: progressIntervalMinutes || null, workItemBudgetUsd: workItemBudgetUsd || null, - agentEngine: agentEngine || null, - engineSettings: Object.keys(engineSettings).length > 0 ? engineSettings : null, runLinksEnabled, }); } - const effectiveEngineId = agentEngine || ''; - const effectiveEngine = enginesQuery.data?.find((engine) => engine.id === effectiveEngineId); - return (
@@ -128,10 +91,6 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
-
- - -
-
-
-
- - setMaxIterations(e.target.value)} - placeholder="e.g. 20" - /> -
-
- - -
- setEngineSettings(next ?? {})} - />
> | null; +} + +function numericFieldDefault(value: number | null | undefined): string { + return value != null ? String(value) : ''; +} + +export function ProjectHarnessForm({ project }: { project: Project }) { + const updateMutation = useProjectUpdate(project.id); + const enginesQuery = useQuery(trpc.agentConfigs.engines.queryOptions()); + + const [model, setModel] = useState(project.model ?? ''); + const [maxIterations, setMaxIterations] = useState(numericFieldDefault(project.maxIterations)); + const [agentEngine, setAgentEngine] = useState(project.agentEngine ?? ''); + const [engineSettings, setEngineSettings] = useState>>( + project.engineSettings ?? {}, + ); + + const effectiveEngineId = agentEngine || ''; + const effectiveEngine = enginesQuery.data?.find((engine) => engine.id === effectiveEngineId); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + updateMutation.mutate({ + model: model || null, + maxIterations: maxIterations ? Number.parseInt(maxIterations, 10) : null, + agentEngine: agentEngine || null, + engineSettings: Object.keys(engineSettings).length > 0 ? engineSettings : null, + }); + } + + return ( + +
+ + +
+ setEngineSettings(next ?? {})} + /> +
+
+ + +
+
+ + setMaxIterations(e.target.value)} + placeholder="e.g. 20" + /> +
+
+
+ + {updateMutation.isSuccess && Saved} + {updateMutation.isError && ( + {updateMutation.error.message} + )} +
+ + ); +} diff --git a/web/src/components/projects/use-project-update.ts b/web/src/components/projects/use-project-update.ts new file mode 100644 index 00000000..9d175003 --- /dev/null +++ b/web/src/components/projects/use-project-update.ts @@ -0,0 +1,24 @@ +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +type ProjectUpdateInput = Parameters[0]; + +/** + * Shared hook for updating a project. + * Both ProjectGeneralForm and ProjectHarnessForm use this to ensure consistent + * cache invalidation and UX behaviour. + */ +export function useProjectUpdate(projectId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: Omit) => + trpcClient.projects.update.mutate({ id: projectId, ...data } as ProjectUpdateInput), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.getById.queryOptions({ id: projectId }).queryKey, + }); + queryClient.invalidateQueries({ queryKey: trpc.projects.listFull.queryOptions().queryKey }); + }, + }); +} diff --git a/web/src/routes/projects/$projectId.tsx b/web/src/routes/projects/$projectId.tsx index 7fd81c29..4f55c3d8 100644 --- a/web/src/routes/projects/$projectId.tsx +++ b/web/src/routes/projects/$projectId.tsx @@ -1,6 +1,7 @@ import { IntegrationForm } from '@/components/projects/integration-form.js'; import { ProjectAgentConfigs } from '@/components/projects/project-agent-configs.js'; import { ProjectGeneralForm } from '@/components/projects/project-general-form.js'; +import { ProjectHarnessForm } from '@/components/projects/project-harness-form.js'; import { ProjectWorkTable } from '@/components/projects/project-work-table.js'; import { ProjectWorkDurationChart } from '@/components/runs/project-work-duration-chart.js'; import { WorkItemCostChart } from '@/components/runs/work-item-cost-chart.js'; @@ -12,7 +13,7 @@ import { ArrowLeft } from 'lucide-react'; import { useState } from 'react'; import { rootRoute } from '../__root.js'; -type Tab = 'general' | 'work' | 'integrations' | 'agent-configs'; +type Tab = 'general' | 'harness' | 'work' | 'integrations' | 'agent-configs'; const WORK_PAGE_SIZE = 50; @@ -43,6 +44,7 @@ function ProjectDetailPage() { const tabs: { id: Tab; label: string }[] = [ { id: 'general', label: 'General' }, + { id: 'harness', label: 'Harness' }, { id: 'work', label: 'Work' }, { id: 'integrations', label: 'Integrations' }, { id: 'agent-configs', label: 'Agent Configs' }, @@ -84,6 +86,8 @@ function ProjectDetailPage() { {activeTab === 'general' && } + {activeTab === 'harness' && } + {activeTab === 'work' && (