Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 2 additions & 81 deletions web/src/components/projects/project-general-form.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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),
);
Expand All @@ -51,47 +38,23 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
project.progressIntervalMinutes ?? '',
);
const [workItemBudgetUsd, setWorkItemBudgetUsd] = useState(project.workItemBudgetUsd ?? '');
const [agentEngine, setAgentEngine] = useState(project.agentEngine ?? '');
const [engineSettings, setEngineSettings] = useState<Record<string, Record<string, unknown>>>(
project.engineSettings ?? {},
);
const [runLinksEnabled, setRunLinksEnabled] = useState(project.runLinksEnabled ?? false);

const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) =>
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({
name,
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 (
<form onSubmit={handleSubmit} className="max-w-2xl space-y-4">
<div className="grid grid-cols-2 gap-4">
Expand Down Expand Up @@ -128,10 +91,6 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<ModelField id="model" value={model} onChange={setModel} engine={effectiveEngineId} />
</div>
<div className="space-y-2">
<Label htmlFor="workItemBudgetUsd">Work Item Budget (USD)</Label>
<Input
Expand All @@ -141,19 +100,6 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
placeholder="e.g. 5.00"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxIterations">Max Iterations</Label>
<Input
id="maxIterations"
type="number"
min="1"
value={maxIterations}
onChange={(e) => setMaxIterations(e.target.value)}
placeholder="e.g. 20"
/>
</div>
<div className="space-y-2">
<Label htmlFor="watchdogTimeoutMs">Watchdog Timeout (ms)</Label>
<Input
Expand Down Expand Up @@ -188,31 +134,6 @@ export function ProjectGeneralForm({ project }: { project: Project }) {
/>
</div>
</div>
<div className="space-y-2">
<Label>Agent Engine</Label>
<Select
value={agentEngine || '_none'}
onValueChange={(v) => setAgentEngine(v === '_none' ? '' : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select engine" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">None</SelectItem>
{enginesQuery.data?.map((engine) => (
<SelectItem key={engine.id} value={engine.id}>
{engine.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<EngineSettingsFields
engine={effectiveEngine}
engines={enginesQuery.data}
value={engineSettings}
onChange={(next) => setEngineSettings(next ?? {})}
/>
<div className="flex items-center gap-3">
<input
type="checkbox"
Expand Down
112 changes: 112 additions & 0 deletions web/src/components/projects/project-harness-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useProjectUpdate } from '@/components/projects/use-project-update.js';
import { EngineSettingsFields } from '@/components/settings/engine-settings-fields.js';
import { ModelField } from '@/components/settings/model-field.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 } from '@/lib/trpc.js';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';

interface Project {
id: string;
model: string | null;
maxIterations: number | null;
agentEngine: string | null;
engineSettings: Record<string, Record<string, unknown>> | 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<Record<string, Record<string, unknown>>>(
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 (
<form onSubmit={handleSubmit} className="max-w-2xl space-y-4">
<div className="space-y-2">
<Label>Agent Engine</Label>
<Select
value={agentEngine || '_none'}
onValueChange={(v) => setAgentEngine(v === '_none' ? '' : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select engine" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">None</SelectItem>
{enginesQuery.data?.map((engine) => (
<SelectItem key={engine.id} value={engine.id}>
{engine.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<EngineSettingsFields
engine={effectiveEngine}
engines={enginesQuery.data}
value={engineSettings}
onChange={(next) => setEngineSettings(next ?? {})}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<ModelField id="model" value={model} onChange={setModel} engine={effectiveEngineId} />
</div>
<div className="space-y-2">
<Label htmlFor="maxIterations">Max Iterations</Label>
<Input
id="maxIterations"
type="number"
min="1"
value={maxIterations}
onChange={(e) => setMaxIterations(e.target.value)}
placeholder="e.g. 20"
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={updateMutation.isPending}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
{updateMutation.isSuccess && <span className="text-sm text-muted-foreground">Saved</span>}
{updateMutation.isError && (
<span className="text-sm text-destructive">{updateMutation.error.message}</span>
)}
</div>
</form>
);
}
24 changes: 24 additions & 0 deletions web/src/components/projects/use-project-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { trpc, trpcClient } from '@/lib/trpc.js';
import { useMutation, useQueryClient } from '@tanstack/react-query';

type ProjectUpdateInput = Parameters<typeof trpcClient.projects.update.mutate>[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<ProjectUpdateInput, 'id'>) =>
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 });
},
});
}
6 changes: 5 additions & 1 deletion web/src/routes/projects/$projectId.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -84,6 +86,8 @@ function ProjectDetailPage() {

{activeTab === 'general' && <ProjectGeneralForm project={project} />}

{activeTab === 'harness' && <ProjectHarnessForm project={project} />}

{activeTab === 'work' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
Expand Down
Loading