diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index b01f939..66d0a1c 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -10,6 +10,7 @@ import type { JobSource, PipelineRun, AppSettings, + ResumeProjectsSettings, } from '../../shared/types'; const API_BASE = '/api'; @@ -102,6 +103,7 @@ export async function updateSettings(update: { model?: string | null pipelineWebhookUrl?: string | null jobCompleteWebhookUrl?: string | null + resumeProjects?: ResumeProjectsSettings | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 92b34bc..41bc64e 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -7,16 +7,41 @@ import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" -import type { AppSettings } from "../../shared/types" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import type { AppSettings, ResumeProjectsSettings } from "../../shared/types" import * as api from "../api" +function arraysEqual(a: string[], b: string[]) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) { + return ( + a.maxProjects === b.maxProjects && + arraysEqual(a.lockedProjectIds, b.lockedProjectIds) && + arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds) + ) +} + +function clampInt(value: number, min: number, max: number) { + const int = Math.floor(value) + if (Number.isNaN(int)) return min + return Math.min(max, Math.max(min, int)) +} + export const SettingsPage: React.FC = () => { const [settings, setSettings] = useState(null) const [modelDraft, setModelDraft] = useState("") const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("") const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("") + const [resumeProjectsDraft, setResumeProjectsDraft] = useState(null) const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) @@ -31,6 +56,7 @@ export const SettingsPage: React.FC = () => { setModelDraft(data.overrideModel ?? "") setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "") setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "") + setResumeProjectsDraft(data.resumeProjects) }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to load settings" @@ -55,9 +81,12 @@ export const SettingsPage: React.FC = () => { const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? "" const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? "" const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl + const profileProjects = settings?.profileProjects ?? [] + const maxProjectsTotal = profileProjects.length + const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0 const canSave = useMemo(() => { - if (!settings) return false + if (!settings || !resumeProjectsDraft) return false const next = modelDraft.trim() const current = (overrideModel ?? "").trim() const nextWebhook = pipelineWebhookUrlDraft.trim() @@ -67,7 +96,8 @@ export const SettingsPage: React.FC = () => { return ( next !== current || nextWebhook !== currentWebhook || - nextJobCompleteWebhook !== currentJobCompleteWebhook + nextJobCompleteWebhook !== currentJobCompleteWebhook || + !resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects) ) }, [ settings, @@ -77,24 +107,30 @@ export const SettingsPage: React.FC = () => { overrideModel, overridePipelineWebhookUrl, overrideJobCompleteWebhookUrl, + resumeProjectsDraft, ]) const handleSave = async () => { - if (!settings) return + if (!settings || !resumeProjectsDraft) return try { setIsSaving(true) const trimmed = modelDraft.trim() const webhookTrimmed = pipelineWebhookUrlDraft.trim() const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim() + const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects) + ? null + : resumeProjectsDraft const updated = await api.updateSettings({ model: trimmed.length > 0 ? trimmed : null, pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null, jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null, + resumeProjects: resumeProjectsOverride, }) setSettings(updated) setModelDraft(updated.overrideModel ?? "") setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "") setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "") + setResumeProjectsDraft(updated.resumeProjects) toast.success("Settings saved") } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings" @@ -107,11 +143,17 @@ export const SettingsPage: React.FC = () => { const handleReset = async () => { try { setIsSaving(true) - const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null }) + const updated = await api.updateSettings({ + model: null, + pipelineWebhookUrl: null, + jobCompleteWebhookUrl: null, + resumeProjects: null, + }) setSettings(updated) setModelDraft("") setPipelineWebhookUrlDraft("") setJobCompleteWebhookUrlDraft("") + setResumeProjectsDraft(updated.resumeProjects) toast.success("Reset to default") } catch (error) { const message = error instanceof Error ? error.message : "Failed to reset settings" @@ -230,6 +272,122 @@ export const SettingsPage: React.FC = () => { + + + Resume Projects + + + +
+
Max projects included
+ { + if (!resumeProjectsDraft) return + const next = Number(event.target.value) + const clamped = clampInt(next, lockedCount, maxProjectsTotal) + setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped }) + }} + disabled={isLoading || isSaving || !resumeProjectsDraft} + /> +
+ Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "} + {resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal} +
+
+ + + + + + + Project + Base visible + Locked + AI selectable + + + + {profileProjects.map((project) => { + const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id)) + const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id)) + const excluded = !locked && !aiSelectable + + return ( + + +
+
{project.name || project.id}
+
+ {[project.description, project.date].filter(Boolean).join(" · ")} + {excluded ? " · Excluded" : ""} +
+
+
+ {project.isVisibleInBase ? "Yes" : "No"} + + { + if (!resumeProjectsDraft) return + const isChecked = checked === true + const lockedIds = resumeProjectsDraft.lockedProjectIds.slice() + const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice() + + if (isChecked) { + if (!lockedIds.includes(project.id)) lockedIds.push(project.id) + const nextSelectable = selectableIds.filter((id) => id !== project.id) + const minCap = lockedIds.length + setResumeProjectsDraft({ + ...resumeProjectsDraft, + lockedProjectIds: lockedIds, + aiSelectableProjectIds: nextSelectable, + maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap), + }) + return + } + + const nextLocked = lockedIds.filter((id) => id !== project.id) + if (!selectableIds.includes(project.id)) selectableIds.push(project.id) + setResumeProjectsDraft({ + ...resumeProjectsDraft, + lockedProjectIds: nextLocked, + aiSelectableProjectIds: selectableIds, + maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal), + }) + }} + /> + + + { + if (!resumeProjectsDraft) return + const isChecked = checked === true + const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice() + const nextSelectable = isChecked + ? selectableIds.includes(project.id) + ? selectableIds + : [...selectableIds, project.id] + : selectableIds.filter((id) => id !== project.id) + setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable }) + }} + /> + +
+ ) + })} +
+
+
+
+