From 60788b0f6a71896e582b593424acdac713c32016 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Sat, 7 Feb 2026 21:48:44 +0000 Subject: [PATCH] modal to configure pipeline settings on pipeline runs (#99) * feat(orchestrator): add unified run modal shell with Automatic/Manual tabs * feat(orchestrator): implement Automatic tab presets, estimate, and save+run flow * refactor(manual-import): reuse manual import flow inside unified run modal * refactor(settings): move pipeline tuning out of settings page into run modal * stage 5 * jobs per term simplified * copy improvement * pill input * better UI * style(orchestrator): align run settings inputs on one row * style(orchestrator): remove hover and pointer affordance from term pills * style(orchestrator): restore hover and pointer affordance for term pills * style(orchestrator): make search term pill hover more prominent * better hover * refactor(orchestrator): use react-hook-form in automatic run panel * formatting * fix(orchestrator): resolve biome issues in automatic run modal * better copy * feat(orchestrator): auto-select custom preset on manual config changes * remove badge * feat(orchestrator): redesign automatic run panel with collapsible advanced settings * refactor(orchestrator): move estimate summary to footer and dedupe sources * style(orchestrator): separate search term input from term pills * style(orchestrator): remove save preset action from automatic footer * ux(orchestrator): make entire search term pill tap-to-remove * remove badge * remove badge * fix(orchestrator): return zero estimate when search terms are empty --- .../client/components/ManualImportFlow.tsx | 563 +++++++++++++++ .../client/components/ManualImportSheet.tsx | 673 +----------------- orchestrator/src/client/lib/version.ts | 14 +- .../client/pages/OrchestratorPage.test.tsx | 71 ++ .../src/client/pages/OrchestratorPage.tsx | 124 ++-- .../src/client/pages/SettingsPage.test.tsx | 23 +- .../src/client/pages/SettingsPage.tsx | 24 - .../pages/orchestrator/AutomaticRunTab.tsx | 421 +++++++++++ .../orchestrator/OrchestratorHeader.test.tsx | 105 +-- .../pages/orchestrator/OrchestratorHeader.tsx | 115 +-- .../pages/orchestrator/RunModeModal.test.tsx | 36 + .../pages/orchestrator/RunModeModal.tsx | 93 +++ .../pages/orchestrator/automatic-run.test.ts | 77 ++ .../pages/orchestrator/automatic-run.ts | 196 +++++ .../src/client/pages/orchestrator/run-mode.ts | 1 + .../src/server/api/routes/pipeline.ts | 72 +- 16 files changed, 1637 insertions(+), 971 deletions(-) create mode 100644 orchestrator/src/client/components/ManualImportFlow.tsx create mode 100644 orchestrator/src/client/pages/orchestrator/AutomaticRunTab.tsx create mode 100644 orchestrator/src/client/pages/orchestrator/RunModeModal.test.tsx create mode 100644 orchestrator/src/client/pages/orchestrator/RunModeModal.tsx create mode 100644 orchestrator/src/client/pages/orchestrator/automatic-run.test.ts create mode 100644 orchestrator/src/client/pages/orchestrator/automatic-run.ts create mode 100644 orchestrator/src/client/pages/orchestrator/run-mode.ts diff --git a/orchestrator/src/client/components/ManualImportFlow.tsx b/orchestrator/src/client/components/ManualImportFlow.tsx new file mode 100644 index 0000000..a6f3f3d --- /dev/null +++ b/orchestrator/src/client/components/ManualImportFlow.tsx @@ -0,0 +1,563 @@ +import * as api from "@client/api"; +import type { ManualJobDraft } from "@shared/types.js"; +import { + ArrowLeft, + ClipboardPaste, + Link, + Loader2, + Sparkles, +} from "lucide-react"; +import type React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; + +type ManualImportStep = "paste" | "loading" | "review"; + +type ManualJobDraftState = { + title: string; + employer: string; + jobUrl: string; + applicationLink: string; + location: string; + salary: string; + deadline: string; + jobDescription: string; + jobType: string; + jobLevel: string; + jobFunction: string; + disciplines: string; + degreeRequired: string; + starting: string; +}; + +const emptyDraft: ManualJobDraftState = { + title: "", + employer: "", + jobUrl: "", + applicationLink: "", + location: "", + salary: "", + deadline: "", + jobDescription: "", + jobType: "", + jobLevel: "", + jobFunction: "", + disciplines: "", + degreeRequired: "", + starting: "", +}; + +const normalizeDraft = ( + draft?: ManualJobDraft | null, + jd?: string, +): ManualJobDraftState => ({ + ...emptyDraft, + title: draft?.title ?? "", + employer: draft?.employer ?? "", + jobUrl: draft?.jobUrl ?? "", + applicationLink: draft?.applicationLink ?? "", + location: draft?.location ?? "", + salary: draft?.salary ?? "", + deadline: draft?.deadline ?? "", + jobDescription: jd ?? draft?.jobDescription ?? "", + jobType: draft?.jobType ?? "", + jobLevel: draft?.jobLevel ?? "", + jobFunction: draft?.jobFunction ?? "", + disciplines: draft?.disciplines ?? "", + degreeRequired: draft?.degreeRequired ?? "", + starting: draft?.starting ?? "", +}); + +const toPayload = (draft: ManualJobDraftState): ManualJobDraft => { + const clean = (value: string) => { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + }; + + return { + title: clean(draft.title), + employer: clean(draft.employer), + jobUrl: clean(draft.jobUrl), + applicationLink: clean(draft.applicationLink), + location: clean(draft.location), + salary: clean(draft.salary), + deadline: clean(draft.deadline), + jobDescription: clean(draft.jobDescription), + jobType: clean(draft.jobType), + jobLevel: clean(draft.jobLevel), + jobFunction: clean(draft.jobFunction), + disciplines: clean(draft.disciplines), + degreeRequired: clean(draft.degreeRequired), + starting: clean(draft.starting), + }; +}; + +interface ManualImportFlowProps { + active: boolean; + onImported: (jobId: string) => void | Promise; + onClose: () => void; +} + +export const ManualImportFlow: React.FC = ({ + active, + onImported, + onClose, +}) => { + const [step, setStep] = useState("paste"); + const [rawDescription, setRawDescription] = useState(""); + const [fetchUrl, setFetchUrl] = useState(""); + const [isFetching, setIsFetching] = useState(false); + const [draft, setDraft] = useState(emptyDraft); + const [warning, setWarning] = useState(null); + const [error, setError] = useState(null); + const [isImporting, setIsImporting] = useState(false); + + useEffect(() => { + if (active) return; + setStep("paste"); + setRawDescription(""); + setFetchUrl(""); + setIsFetching(false); + setDraft(emptyDraft); + setWarning(null); + setError(null); + setIsImporting(false); + }, [active]); + + const stepIndex = step === "paste" ? 0 : step === "loading" ? 1 : 2; + const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex]; + + const canAnalyze = rawDescription.trim().length > 0 && step !== "loading"; + const canFetch = + fetchUrl.trim().length > 0 && !isFetching && step === "paste"; + const canImport = useMemo(() => { + if (step !== "review") return false; + return ( + draft.title.trim().length > 0 && + draft.employer.trim().length > 0 && + draft.jobDescription.trim().length > 0 + ); + }, [draft, step]); + + const handleFetch = async () => { + if (!fetchUrl.trim()) return; + + try { + setError(null); + setWarning(null); + setIsFetching(true); + + const fetchResponse = await api.fetchJobFromUrl({ url: fetchUrl.trim() }); + const fetchedContent = fetchResponse.content; + const fetchedUrl = fetchResponse.url; + + setIsFetching(false); + setStep("loading"); + const inferResponse = await api.inferManualJob({ + jobDescription: fetchedContent, + }); + const normalized = normalizeDraft(inferResponse.job); + + if (!normalized.jobUrl) { + normalized.jobUrl = fetchedUrl; + } + + setDraft(normalized); + setWarning(inferResponse.warning ?? null); + setStep("review"); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to fetch URL"; + setError(message); + setIsFetching(false); + setStep("paste"); + } + }; + + const handleAnalyze = async () => { + if (!rawDescription.trim()) { + setError("Paste a job description to continue."); + return; + } + + try { + setError(null); + setWarning(null); + setStep("loading"); + const response = await api.inferManualJob({ + jobDescription: rawDescription, + }); + const normalized = normalizeDraft(response.job, rawDescription.trim()); + if (draft.jobUrl && !normalized.jobUrl) { + normalized.jobUrl = draft.jobUrl; + } + setDraft(normalized); + setWarning(response.warning ?? null); + setStep("review"); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to analyze job description"; + setError(message); + setStep("paste"); + } + }; + + const handleImport = async () => { + if (!canImport) return; + + try { + setIsImporting(true); + const payload = toPayload(draft); + const created = await api.importManualJob({ job: payload }); + toast.success("Job imported", { + description: "The job is now in the discovered column.", + }); + await onImported(created.id); + onClose(); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to import job"; + toast.error(message); + } finally { + setIsImporting(false); + } + }; + + return ( +
+
+
+
+ Step {stepIndex + 1} of 3 + {stepLabel} +
+
+
+
+
+ +
+ +
+ {step === "paste" && ( +
+
+ +
+ setFetchUrl(event.target.value)} + placeholder="https://example.com/job-posting" + className="flex-1" + onKeyDown={(event) => { + if (event.key === "Enter" && canFetch) { + event.preventDefault(); + void handleFetch(); + } + }} + /> + +
+
+ +
+ +