From 0e6f0bcf88db50d6bdcec1f96be8bcf578d9e9df Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:43:10 +0000 Subject: [PATCH] Inputs getting unset fix (#91) * codex 5.3 one shot * prevent cursor changing position --- .../src/client/components/ReadyPanel.tsx | 16 +- .../components/TailoringEditor.test.tsx | 126 +++++++++++ .../src/client/components/TailoringEditor.tsx | 150 +++++++++----- .../discovered-panel/DiscoveredPanel.tsx | 16 +- .../discovered-panel/TailorMode.test.tsx | 126 +++++++++++ .../discovered-panel/TailorMode.tsx | 196 ++++++++++++------ .../src/client/pages/OrchestratorPage.tsx | 3 + .../orchestrator/JobDetailPanel.test.tsx | 153 +++++++++----- .../pages/orchestrator/JobDetailPanel.tsx | 21 +- .../orchestrator/useOrchestratorData.test.ts | 129 ++++++++++++ .../pages/orchestrator/useOrchestratorData.ts | 25 ++- 11 files changed, 784 insertions(+), 177 deletions(-) create mode 100644 orchestrator/src/client/components/TailoringEditor.test.tsx create mode 100644 orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx create mode 100644 orchestrator/src/client/pages/orchestrator/useOrchestratorData.test.ts diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 712b586..ab33563 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -52,6 +52,7 @@ interface ReadyPanelProps { job: Job | null; onJobUpdated: () => void | Promise; onJobMoved: (jobId: string) => void; + onTailoringDirtyChange?: (isDirty: boolean) => void; } const safeFilenamePart = (value: string | null | undefined) => @@ -61,6 +62,7 @@ export const ReadyPanel: React.FC = ({ job, onJobUpdated, onJobMoved, + onTailoringDirtyChange, }) => { const [mode, setMode] = useState("ready"); const [isMarkingApplied, setIsMarkingApplied] = useState(false); @@ -84,7 +86,18 @@ export const ReadyPanel: React.FC = ({ // Reset mode when job changes useEffect(() => { setMode("ready"); - }, []); + onTailoringDirtyChange?.(false); + }, [job?.id, onTailoringDirtyChange]); + + useEffect(() => { + if (mode !== "tailor") { + onTailoringDirtyChange?.(false); + } + }, [mode, onTailoringDirtyChange]); + + useEffect(() => { + return () => onTailoringDirtyChange?.(false); + }, [onTailoringDirtyChange]); // Compute derived values const pdfHref = job @@ -252,6 +265,7 @@ export const ReadyPanel: React.FC = ({ onFinalize={handleTailorFinalize} isFinalizing={isRegenerating} variant="ready" + onDirtyChange={onTailoringDirtyChange} /> ); } diff --git a/orchestrator/src/client/components/TailoringEditor.test.tsx b/orchestrator/src/client/components/TailoringEditor.test.tsx new file mode 100644 index 0000000..d8ba7b1 --- /dev/null +++ b/orchestrator/src/client/components/TailoringEditor.test.tsx @@ -0,0 +1,126 @@ +import type { Job } from "@shared/types.js"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../api"; +import { TailoringEditor } from "./TailoringEditor"; + +vi.mock("../api", () => ({ + getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), + updateJob: vi.fn().mockResolvedValue({}), + summarizeJob: vi.fn(), + generateJobPdf: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const createJob = (overrides: Partial = {}): Job => + ({ + id: "job-1", + tailoredSummary: "Saved summary", + jobDescription: "Saved description", + selectedProjectIds: "p1", + ...overrides, + }) as Job; + +describe("TailoringEditor", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not rehydrate local edits from same-job prop updates", async () => { + const { rerender } = render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + fireEvent.change(screen.getByLabelText("Tailored Summary"), { + target: { value: "Local draft" }, + }); + + rerender( + , + ); + + expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft"); + }); + + it("resets local state when job id changes", async () => { + const { rerender } = render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + fireEvent.change(screen.getByLabelText("Tailored Summary"), { + target: { value: "Local draft" }, + }); + + rerender( + , + ); + + expect(screen.getByLabelText("Tailored Summary")).toHaveValue("New job summary"); + }); + + it("emits dirty state changes", async () => { + const onDirtyChange = vi.fn(); + render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + fireEvent.change(screen.getByLabelText("Tailored Summary"), { + target: { value: "Local draft" }, + }); + + expect(onDirtyChange).toHaveBeenCalledWith(true); + }); + + it("does not sync same-job props while summary field is focused", async () => { + const { rerender } = render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + const summary = screen.getByLabelText("Tailored Summary"); + fireEvent.focus(summary); + + rerender( + , + ); + + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Saved summary", + ); + }); +}); diff --git a/orchestrator/src/client/components/TailoringEditor.tsx b/orchestrator/src/client/components/TailoringEditor.tsx index a149af4..da42e6d 100644 --- a/orchestrator/src/client/components/TailoringEditor.tsx +++ b/orchestrator/src/client/components/TailoringEditor.tsx @@ -7,7 +7,7 @@ import { Sparkles, } from "lucide-react"; import type React from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -22,6 +22,17 @@ interface TailoringEditorProps { onBeforeGenerate?: () => boolean | Promise; } +const parseSelectedIds = (value: string | null | undefined) => + new Set(value?.split(",").filter(Boolean) ?? []); + +const hasSelectionDiff = (current: Set, saved: Set) => { + if (current.size !== saved.size) return true; + for (const id of current) { + if (!saved.has(id)) return true; + } + return false; +}; + export const TailoringEditor: React.FC = ({ job, onUpdate, @@ -31,52 +42,88 @@ export const TailoringEditor: React.FC = ({ }) => { const [catalog, setCatalog] = useState([]); const [summary, setSummary] = useState(job.tailoredSummary || ""); - const [jobDescription, setJobDescription] = useState( - job.jobDescription || "", + const [jobDescription, setJobDescription] = useState(job.jobDescription || ""); + const [selectedIds, setSelectedIds] = useState>(() => + parseSelectedIds(job.selectedProjectIds), + ); + const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || ""); + const [savedDescription, setSavedDescription] = useState(job.jobDescription || ""); + const [savedSelectedIds, setSavedSelectedIds] = useState>(() => + parseSelectedIds(job.selectedProjectIds), ); - const [selectedIds, setSelectedIds] = useState>(new Set()); const [isSummarizing, setIsSummarizing] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [activeField, setActiveField] = useState<"summary" | "description" | null>( + null, + ); + const lastJobIdRef = useRef(job.id); - const savedSelectedIds = useMemo(() => { - const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; - return new Set(saved); - }, [job.selectedProjectIds]); - - const hasSelectionDiff = useMemo(() => { - if (selectedIds.size !== savedSelectedIds.size) return true; - for (const id of selectedIds) { - if (!savedSelectedIds.has(id)) return true; - } - return false; - }, [selectedIds, savedSelectedIds]); - - const isDirty = - summary !== (job.tailoredSummary || "") || - jobDescription !== (job.jobDescription || "") || - hasSelectionDiff; + const isDirty = useMemo(() => { + if (summary !== savedSummary) return true; + if (jobDescription !== savedDescription) return true; + return hasSelectionDiff(selectedIds, savedSelectedIds); + }, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedSelectedIds]); useEffect(() => { onDirtyChange?.(isDirty); }, [isDirty, onDirtyChange]); useEffect(() => { - // Load project catalog - api.getResumeProjectsCatalog().then(setCatalog).catch(console.error); - - // Set initial selection - if (job.selectedProjectIds) { - setSelectedIds( - new Set(job.selectedProjectIds.split(",").filter(Boolean)), - ); - } - setJobDescription(job.jobDescription || ""); - }, [job.selectedProjectIds, job.jobDescription]); + return () => onDirtyChange?.(false); + }, [onDirtyChange]); useEffect(() => { - setSummary(job.tailoredSummary || ""); - }, [job.tailoredSummary]); + api.getResumeProjectsCatalog().then(setCatalog).catch(console.error); + }, []); + + useEffect(() => { + const incomingSummary = job.tailoredSummary || ""; + const incomingDescription = job.jobDescription || ""; + const incomingSelectedIds = parseSelectedIds(job.selectedProjectIds); + + if (job.id !== lastJobIdRef.current) { + lastJobIdRef.current = job.id; + setSummary(incomingSummary); + setJobDescription(incomingDescription); + setSelectedIds(incomingSelectedIds); + setSavedSummary(incomingSummary); + setSavedDescription(incomingDescription); + setSavedSelectedIds(incomingSelectedIds); + return; + } + + if (isDirty || activeField !== null) return; + + setSummary(incomingSummary); + setJobDescription(incomingDescription); + setSelectedIds(incomingSelectedIds); + setSavedSummary(incomingSummary); + setSavedDescription(incomingDescription); + setSavedSelectedIds(incomingSelectedIds); + }, [ + job.id, + job.tailoredSummary, + job.jobDescription, + job.selectedProjectIds, + isDirty, + activeField, + ]); + + const syncSavedSnapshot = useCallback( + ( + nextSummary: string, + nextDescription: string, + nextSelectedIds: Set, + ) => { + setSavedSummary(nextSummary); + setSavedDescription(nextDescription); + setSavedSelectedIds(new Set(nextSelectedIds)); + }, + [], + ); + + const selectedIdsCsv = useMemo(() => Array.from(selectedIds).join(","), [selectedIds]); const saveChanges = useCallback( async ({ showToast = true }: { showToast?: boolean } = {}) => { @@ -84,9 +131,10 @@ export const TailoringEditor: React.FC = ({ setIsSaving(true); await api.updateJob(job.id, { tailoredSummary: summary, - jobDescription: jobDescription, - selectedProjectIds: Array.from(selectedIds).join(","), + jobDescription, + selectedProjectIds: selectedIdsCsv, }); + syncSavedSnapshot(summary, jobDescription, selectedIds); if (showToast) toast.success("Changes saved"); await onUpdate(); } catch (error) { @@ -96,7 +144,7 @@ export const TailoringEditor: React.FC = ({ setIsSaving(false); } }, - [job.id, onUpdate, selectedIds, summary, jobDescription], + [job.id, onUpdate, selectedIdsCsv, selectedIds, summary, jobDescription, syncSavedSnapshot], ); useEffect(() => { @@ -121,18 +169,17 @@ export const TailoringEditor: React.FC = ({ const handleSummarize = async () => { try { setIsSummarizing(true); - // Save changes first so AI uses latest description if (isDirty) { await saveChanges({ showToast: false }); } const updatedJob = await api.summarizeJob(job.id, { force: true }); - setSummary(updatedJob.tailoredSummary || ""); - setJobDescription(updatedJob.jobDescription || ""); - if (updatedJob.selectedProjectIds) { - setSelectedIds( - new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)), - ); - } + const nextSummary = updatedJob.tailoredSummary || ""; + const nextDescription = updatedJob.jobDescription || ""; + const nextSelectedIds = parseSelectedIds(updatedJob.selectedProjectIds); + setSummary(nextSummary); + setJobDescription(nextDescription); + setSelectedIds(nextSelectedIds); + syncSavedSnapshot(nextSummary, nextDescription, nextSelectedIds); toast.success("AI Summary & Projects generated"); await onUpdate(); } catch (error) { @@ -150,7 +197,6 @@ export const TailoringEditor: React.FC = ({ if (shouldProceed === false) return; setIsGeneratingPdf(true); - // Save current state first to ensure PDF uses latest await saveChanges({ showToast: false }); await api.generateJobPdf(job.id); @@ -163,7 +209,7 @@ export const TailoringEditor: React.FC = ({ } }; - const maxProjects = 3; // Example limit, could come from settings + const maxProjects = 3; const tooManyProjects = selectedIds.size > maxProjects; return ( @@ -211,6 +257,12 @@ export const TailoringEditor: React.FC = ({ className="w-full min-h-[120px] max-h-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" value={jobDescription} onChange={(e) => setJobDescription(e.target.value)} + onFocus={() => setActiveField("description")} + onBlur={() => + setActiveField((prev) => + prev === "description" ? null : prev, + ) + } placeholder="The raw job description..." /> @@ -226,6 +278,10 @@ export const TailoringEditor: React.FC = ({ className="w-full min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" value={summary} onChange={(e) => setSummary(e.target.value)} + onFocus={() => setActiveField("summary")} + onBlur={() => + setActiveField((prev) => (prev === "summary" ? null : prev)) + } placeholder="AI will generate this, or you can write your own..." /> diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx index 6bcea41..33baf6e 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx @@ -15,12 +15,14 @@ interface DiscoveredPanelProps { job: Job | null; onJobUpdated: () => void | Promise; onJobMoved: (jobId: string) => void; + onTailoringDirtyChange?: (isDirty: boolean) => void; } export const DiscoveredPanel: React.FC = ({ job, onJobUpdated, onJobMoved, + onTailoringDirtyChange, }) => { const [mode, setMode] = useState("decide"); const [isSkipping, setIsSkipping] = useState(false); @@ -31,7 +33,18 @@ export const DiscoveredPanel: React.FC = ({ setMode("decide"); setIsSkipping(false); setIsFinalizing(false); - }, []); + onTailoringDirtyChange?.(false); + }, [job?.id, onTailoringDirtyChange]); + + useEffect(() => { + if (mode !== "tailor") { + onTailoringDirtyChange?.(false); + } + }, [mode, onTailoringDirtyChange]); + + useEffect(() => { + return () => onTailoringDirtyChange?.(false); + }, [onTailoringDirtyChange]); const handleSkip = async () => { if (!job) return; @@ -102,6 +115,7 @@ export const DiscoveredPanel: React.FC = ({ onBack={() => setMode("decide")} onFinalize={handleFinalize} isFinalizing={isFinalizing} + onDirtyChange={onTailoringDirtyChange} /> )} diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx new file mode 100644 index 0000000..950768d --- /dev/null +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx @@ -0,0 +1,126 @@ +import type { Job } from "@shared/types.js"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../../api"; +import { TailorMode } from "./TailorMode"; + +vi.mock("../../api", () => ({ + getResumeProjectsCatalog: vi.fn().mockResolvedValue([]), + updateJob: vi.fn(), + summarizeJob: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const createJob = (overrides: Partial = {}): Job => + ({ + id: "job-1", + tailoredSummary: "Saved summary", + jobDescription: "Saved description", + selectedProjectIds: "p1", + ...overrides, + }) as Job; + +describe("TailorMode", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not rehydrate local edits from same-job prop updates", async () => { + const { rerender } = render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + fireEvent.change(screen.getByLabelText("Tailored Summary"), { + target: { value: "Local draft" }, + }); + + rerender( + , + ); + + expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft"); + }); + + it("resets local state when job id changes", async () => { + const { rerender } = render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + fireEvent.change(screen.getByLabelText("Tailored Summary"), { + target: { value: "Local draft" }, + }); + + rerender( + , + ); + + expect(screen.getByLabelText("Tailored Summary")).toHaveValue("New job summary"); + }); + + it("does not sync same-job props while summary field is focused", async () => { + const { rerender } = render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + const summary = screen.getByLabelText("Tailored Summary"); + fireEvent.focus(summary); + + rerender( + , + ); + + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Saved summary", + ); + }); +}); diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx index 5703256..e5d3ead 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx @@ -1,7 +1,7 @@ import type { Job, ResumeProjectCatalogItem } from "@shared/types.js"; import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react"; import type React from "react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; @@ -14,94 +14,155 @@ interface TailorModeProps { onBack: () => void; onFinalize: () => void; isFinalizing: boolean; + onDirtyChange?: (isDirty: boolean) => void; /** Variant controls the finalize button text. Default is 'discovered'. */ variant?: "discovered" | "ready"; } +const parseSelectedIds = (value: string | null | undefined) => + new Set(value?.split(",").filter(Boolean) ?? []); + +const hasSelectionDiff = (current: Set, saved: Set) => { + if (current.size !== saved.size) return true; + for (const id of current) { + if (!saved.has(id)) return true; + } + return false; +}; + export const TailorMode: React.FC = ({ job, onBack, onFinalize, isFinalizing, + onDirtyChange, variant = "discovered", }) => { const [catalog, setCatalog] = useState([]); const [summary, setSummary] = useState(job.tailoredSummary || ""); - const [jobDescription, setJobDescription] = useState( - job.jobDescription || "", + const [jobDescription, setJobDescription] = useState(job.jobDescription || ""); + const [selectedIds, setSelectedIds] = useState>(() => + parseSelectedIds(job.selectedProjectIds), ); - const [selectedIds, setSelectedIds] = useState>(() => { - const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; - return new Set(saved); - }); + + const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || ""); + const [savedDescription, setSavedDescription] = useState(job.jobDescription || ""); + const [savedSelectedIds, setSavedSelectedIds] = useState>(() => + parseSelectedIds(job.selectedProjectIds), + ); + const [isGenerating, setIsGenerating] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [draftStatus, setDraftStatus] = useState< - "unsaved" | "saving" | "saved" - >("saved"); + const [draftStatus, setDraftStatus] = useState<"unsaved" | "saving" | "saved">( + "saved", + ); const [showDescription, setShowDescription] = useState(false); + const [activeField, setActiveField] = useState<"summary" | "description" | null>( + null, + ); + const lastJobIdRef = useRef(job.id); useEffect(() => { api.getResumeProjectsCatalog().then(setCatalog).catch(console.error); }, []); - useEffect(() => { - setSummary(job.tailoredSummary || ""); - setJobDescription(job.jobDescription || ""); - const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; - setSelectedIds(new Set(saved)); - setDraftStatus("saved"); - }, [job.tailoredSummary, job.selectedProjectIds, job.jobDescription]); - - const savedSummary = job.tailoredSummary || ""; - const savedDescription = job.jobDescription || ""; - const savedIds = useMemo(() => { - const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? []; - return new Set(saved); - }, [job.selectedProjectIds]); - - const hasChanges = useMemo(() => { + const isDirty = useMemo(() => { if (summary !== savedSummary) return true; if (jobDescription !== savedDescription) return true; - if (selectedIds.size !== savedIds.size) return true; - for (const id of selectedIds) { - if (!savedIds.has(id)) return true; + return hasSelectionDiff(selectedIds, savedSelectedIds); + }, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedSelectedIds]); + + useEffect(() => { + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + + useEffect(() => { + return () => onDirtyChange?.(false); + }, [onDirtyChange]); + + useEffect(() => { + const incomingSummary = job.tailoredSummary || ""; + const incomingDescription = job.jobDescription || ""; + const incomingSelectedIds = parseSelectedIds(job.selectedProjectIds); + + if (job.id !== lastJobIdRef.current) { + lastJobIdRef.current = job.id; + setSummary(incomingSummary); + setJobDescription(incomingDescription); + setSelectedIds(incomingSelectedIds); + setSavedSummary(incomingSummary); + setSavedDescription(incomingDescription); + setSavedSelectedIds(incomingSelectedIds); + setDraftStatus("saved"); + return; } - return false; + + if (isDirty || activeField !== null) return; + + setSummary(incomingSummary); + setJobDescription(incomingDescription); + setSelectedIds(incomingSelectedIds); + setSavedSummary(incomingSummary); + setSavedDescription(incomingDescription); + setSavedSelectedIds(incomingSelectedIds); + setDraftStatus("saved"); }, [ - summary, - savedSummary, - jobDescription, - savedDescription, - selectedIds, - savedIds, + job.id, + job.tailoredSummary, + job.jobDescription, + job.selectedProjectIds, + isDirty, + activeField, ]); useEffect(() => { - if (hasChanges && draftStatus === "saved") { + if (isDirty && draftStatus === "saved") { setDraftStatus("unsaved"); } - }, [hasChanges, draftStatus]); + if (!isDirty && draftStatus === "unsaved") { + setDraftStatus("saved"); + } + }, [isDirty, draftStatus]); + + const selectedIdsCsv = useMemo(() => Array.from(selectedIds).join(","), [selectedIds]); + + const syncSavedSnapshot = useCallback( + ( + nextSummary: string, + nextDescription: string, + nextSelectedIds: Set, + ) => { + setSavedSummary(nextSummary); + setSavedDescription(nextDescription); + setSavedSelectedIds(new Set(nextSelectedIds)); + setDraftStatus("saved"); + }, + [], + ); + + const persistCurrent = useCallback(async () => { + await api.updateJob(job.id, { + tailoredSummary: summary, + jobDescription, + selectedProjectIds: selectedIdsCsv, + }); + syncSavedSnapshot(summary, jobDescription, selectedIds); + }, [job.id, summary, jobDescription, selectedIdsCsv, selectedIds, syncSavedSnapshot]); useEffect(() => { - if (!hasChanges || draftStatus !== "unsaved") return; + if (!isDirty || draftStatus !== "unsaved") return; const timeout = setTimeout(async () => { try { setDraftStatus("saving"); - await api.updateJob(job.id, { - tailoredSummary: summary, - jobDescription: jobDescription, - selectedProjectIds: Array.from(selectedIds).join(","), - }); - setDraftStatus("saved"); + await persistCurrent(); } catch { setDraftStatus("unsaved"); } }, 1500); return () => clearTimeout(timeout); - }, [summary, jobDescription, selectedIds, hasChanges, draftStatus, job.id]); + }, [isDirty, draftStatus, persistCurrent]); const handleToggleProject = useCallback( (id: string) => { @@ -120,23 +181,18 @@ export const TailorMode: React.FC = ({ try { setIsGenerating(true); - if (hasChanges) { - await api.updateJob(job.id, { - tailoredSummary: summary, - jobDescription: jobDescription, - selectedProjectIds: Array.from(selectedIds).join(","), - }); + if (isDirty) { + await persistCurrent(); } const updatedJob = await api.summarizeJob(job.id, { force: true }); - setSummary(updatedJob.tailoredSummary || ""); - setJobDescription(updatedJob.jobDescription || ""); - if (updatedJob.selectedProjectIds) { - setSelectedIds( - new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)), - ); - } - setDraftStatus("saved"); + const nextSummary = updatedJob.tailoredSummary || ""; + const nextDescription = updatedJob.jobDescription || ""; + const nextSelectedIds = parseSelectedIds(updatedJob.selectedProjectIds); + setSummary(nextSummary); + setJobDescription(nextDescription); + setSelectedIds(nextSelectedIds); + syncSavedSnapshot(nextSummary, nextDescription, nextSelectedIds); toast.success("Draft generated with AI", { description: "Review and edit before finalizing.", }); @@ -150,14 +206,10 @@ export const TailorMode: React.FC = ({ }; const handleFinalize = async () => { - if (hasChanges) { + if (isDirty) { try { setIsSaving(true); - await api.updateJob(job.id, { - tailoredSummary: summary, - jobDescription: jobDescription, - selectedProjectIds: Array.from(selectedIds).join(","), - }); + await persistCurrent(); } catch { toast.error("Failed to save draft before finalizing"); setIsSaving(false); @@ -193,7 +245,7 @@ export const TailorMode: React.FC = ({ Saving... )} - {draftStatus === "saved" && !hasChanges && ( + {draftStatus === "saved" && !isDirty && ( <> Saved @@ -260,6 +312,12 @@ export const TailorMode: React.FC = ({ className="w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" value={jobDescription} onChange={(event) => setJobDescription(event.target.value)} + onFocus={() => setActiveField("description")} + onBlur={() => + setActiveField((prev) => + prev === "description" ? null : prev, + ) + } placeholder="The raw job description..." disabled={disableInputs} /> @@ -278,6 +336,10 @@ export const TailorMode: React.FC = ({ className="w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" value={summary} onChange={(event) => setSummary(event.target.value)} + onFocus={() => setActiveField("summary")} + onBlur={() => + setActiveField((prev) => (prev === "summary" ? null : prev)) + } placeholder="Write a tailored summary for this role, or generate with AI..." disabled={disableInputs} /> diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index 4b694cf..9e4448a 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -158,6 +158,7 @@ export const OrchestratorPage: React.FC = () => { isLoading, isPipelineRunning, setIsPipelineRunning, + setIsRefreshPaused, loadJobs, } = useOrchestratorData(); const enabledSources = useMemo( @@ -348,6 +349,7 @@ export const OrchestratorPage: React.FC = () => { selectedJob={selectedJob} onSelectJobId={handleSelectJobId} onJobUpdated={loadJobs} + onPauseRefreshChange={setIsRefreshPaused} /> )} @@ -381,6 +383,7 @@ export const OrchestratorPage: React.FC = () => { selectedJob={selectedJob} onSelectJobId={handleSelectJobId} onJobUpdated={loadJobs} + onPauseRefreshChange={setIsRefreshPaused} /> diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx index 3f748c8..2efad58 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx @@ -1,5 +1,5 @@ import type { Job } from "@shared/types.js"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../../api"; @@ -58,7 +58,20 @@ vi.mock("../../components/ReadyPanel", () => ({ })); vi.mock("../../components/TailoringEditor", () => ({ - TailoringEditor: () =>
, + TailoringEditor: ({ + onDirtyChange, + }: { + onDirtyChange?: (isDirty: boolean) => void; + }) => ( +
+ + +
+ ), })); vi.mock("@/lib/utils", async (importOriginal) => { @@ -150,52 +163,62 @@ const createJob = (overrides: Partial = {}): Job => ({ ...overrides, }); +const renderJobDetailPanel = async ( + props: React.ComponentProps, +) => { + const rendered = render(); + await act(async () => { + await Promise.resolve(); + }); + return rendered; +}; + describe("JobDetailPanel", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("renders the discovered panel when active tab is discovered", () => { + it("renders the discovered panel when active tab is discovered", async () => { const job = createJob({ id: "job-99", status: "discovered" }); - render( - , + await renderJobDetailPanel( + { + activeTab: "discovered", + activeJobs: [job], + selectedJob: job, + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + }, ); expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99"); }); - it("shows an empty state when no job is selected", () => { - render( - , + it("shows an empty state when no job is selected", async () => { + await renderJobDetailPanel( + { + activeTab: "all", + activeJobs: [], + selectedJob: null, + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + }, ); expect(screen.getByText("No job selected")).toBeInTheDocument(); }); - it("renders a stripped description preview for html content", () => { - render( - { + await renderJobDetailPanel( + { + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ jobDescription: "

Hello world

", - })} - onSelectJobId={vi.fn()} - onJobUpdated={vi.fn().mockResolvedValue(undefined)} - />, + }), + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + }, ); expect(screen.getByText("Hello world")).toBeInTheDocument(); @@ -205,14 +228,14 @@ describe("JobDetailPanel", () => { const onJobUpdated = vi.fn().mockResolvedValue(undefined); vi.mocked(api.updateJob).mockResolvedValue(undefined as any); - render( - , + await renderJobDetailPanel( + { + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ jobDescription: "Original" }), + onSelectJobId: vi.fn(), + onJobUpdated, + }, ); fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i })); @@ -236,14 +259,14 @@ describe("JobDetailPanel", () => { const onJobUpdated = vi.fn().mockResolvedValue(undefined); vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any); - render( - , + await renderJobDetailPanel( + { + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ status: "ready" }), + onSelectJobId: vi.fn(), + onJobUpdated, + }, ); fireEvent.click(screen.getByRole("button", { name: /applied/i })); @@ -258,14 +281,14 @@ describe("JobDetailPanel", () => { const onJobUpdated = vi.fn().mockResolvedValue(undefined); vi.mocked(api.skipJob).mockResolvedValue(undefined as any); - render( - , + await renderJobDetailPanel( + { + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ status: "ready" }), + onSelectJobId: vi.fn(), + onJobUpdated, + }, ); fireEvent.pointerDown( @@ -277,4 +300,26 @@ describe("JobDetailPanel", () => { await waitFor(() => expect(api.skipJob).toHaveBeenCalledWith("job-1")); expect(onJobUpdated).toHaveBeenCalled(); }); + + it("forwards tailoring dirty state to refresh pause callback", async () => { + const onPauseRefreshChange = vi.fn(); + + await renderJobDetailPanel( + { + activeTab: "all", + activeJobs: [], + selectedJob: createJob({ status: "ready" }), + onSelectJobId: vi.fn(), + onJobUpdated: vi.fn().mockResolvedValue(undefined), + onPauseRefreshChange, + }, + ); + + fireEvent.mouseDown(screen.getByRole("tab", { name: /tailoring/i })); + fireEvent.click(await screen.findByText("Mark tailoring dirty")); + fireEvent.click(screen.getByText("Mark tailoring clean")); + + expect(onPauseRefreshChange).toHaveBeenCalledWith(true); + expect(onPauseRefreshChange).toHaveBeenCalledWith(false); + }); }); diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index 24aa108..1cb71a8 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -50,6 +50,7 @@ interface JobDetailPanelProps { selectedJob: Job | null; onSelectJobId: (jobId: string | null) => void; onJobUpdated: () => Promise; + onPauseRefreshChange?: (paused: boolean) => void; } export const JobDetailPanel: React.FC = ({ @@ -58,6 +59,7 @@ export const JobDetailPanel: React.FC = ({ selectedJob, onSelectJobId, onJobUpdated, + onPauseRefreshChange, }) => { const [detailTab, setDetailTab] = useState< "overview" | "tailoring" | "description" @@ -71,10 +73,23 @@ export const JobDetailPanel: React.FC = ({ const { personName } = useProfile(); + const handleTailoringDirtyChange = useCallback( + (isDirty: boolean) => { + setHasUnsavedTailoring(isDirty); + onPauseRefreshChange?.(isDirty); + }, + [onPauseRefreshChange], + ); + useEffect(() => { setHasUnsavedTailoring(false); saveTailoringRef.current = null; - }, []); + onPauseRefreshChange?.(false); + }, [selectedJob?.id, onPauseRefreshChange]); + + useEffect(() => { + return () => onPauseRefreshChange?.(false); + }, [onPauseRefreshChange]); const description = useMemo(() => { if (!selectedJob?.jobDescription) return "No description available."; @@ -275,6 +290,7 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} + onTailoringDirtyChange={handleTailoringDirtyChange} /> ); } @@ -285,6 +301,7 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} + onTailoringDirtyChange={handleTailoringDirtyChange} /> ); } @@ -534,7 +551,7 @@ export const JobDetailPanel: React.FC = ({ { saveTailoringRef.current = save; }} diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.test.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.test.ts new file mode 100644 index 0000000..02f3594 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.test.ts @@ -0,0 +1,129 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../../api"; +import { useOrchestratorData } from "./useOrchestratorData"; + +vi.mock("../../api", () => ({ + getJobs: vi.fn(), + getPipelineStatus: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { + error: vi.fn(), + }, +})); + +const makeResponse = (jobId: string) => ({ + jobs: [{ id: jobId }], + total: 1, + byStatus: { + discovered: 1, + processing: 0, + ready: 0, + applied: 0, + skipped: 0, + expired: 0, + }, +}); + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +describe("useOrchestratorData", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + vi.mocked(api.getJobs).mockResolvedValue(makeResponse("initial") as any); + vi.mocked(api.getPipelineStatus).mockResolvedValue({ isRunning: false } as any); + }); + + it("applies newest loadJobs response when requests resolve out of order", async () => { + const { result } = renderHook(() => useOrchestratorData()); + + await waitFor(() => { + expect((result.current.jobs[0] as any)?.id).toBe("initial"); + }); + + const first = deferred(); + const second = deferred(); + vi.mocked(api.getJobs) + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + + act(() => { + void result.current.loadJobs(); + void result.current.loadJobs(); + }); + + await act(async () => { + second.resolve(makeResponse("newest")); + await Promise.resolve(); + }); + + await waitFor(() => { + expect((result.current.jobs[0] as any)?.id).toBe("newest"); + }); + + await act(async () => { + first.resolve(makeResponse("stale")); + await Promise.resolve(); + }); + + expect((result.current.jobs[0] as any)?.id).toBe("newest"); + }); + + it("pauses and resumes polling based on isRefreshPaused", async () => { + vi.useFakeTimers(); + vi.mocked(api.getJobs).mockResolvedValue(makeResponse("steady") as any); + + const { result } = renderHook(() => useOrchestratorData()); + + await act(async () => { + await Promise.resolve(); + }); + expect(api.getJobs).toHaveBeenCalledTimes(1); + + act(() => { + result.current.setIsRefreshPaused(true); + }); + + await act(async () => { + await Promise.resolve(); + }); + + const pausedBaselineCalls = vi.mocked(api.getJobs).mock.calls.length; + + await act(async () => { + vi.advanceTimersByTime(10000); + await Promise.resolve(); + }); + + expect(api.getJobs).toHaveBeenCalledTimes(pausedBaselineCalls); + + act(() => { + result.current.setIsRefreshPaused(false); + }); + + const resumedBaselineCalls = vi.mocked(api.getJobs).mock.calls.length; + + await act(async () => { + vi.advanceTimersByTime(10000); + await Promise.resolve(); + }); + + expect(vi.mocked(api.getJobs).mock.calls.length).toBeGreaterThan( + resumedBaselineCalls, + ); + }); +}); diff --git a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts index 29eca95..e78298a 100644 --- a/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts +++ b/orchestrator/src/client/pages/orchestrator/useOrchestratorData.ts @@ -1,5 +1,5 @@ import type { Job, JobStatus } from "@shared/types"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import * as api from "../../api"; @@ -17,19 +17,31 @@ export const useOrchestratorData = () => { const [stats, setStats] = useState>(initialStats); const [isLoading, setIsLoading] = useState(true); const [isPipelineRunning, setIsPipelineRunning] = useState(false); + const [isRefreshPaused, setIsRefreshPaused] = useState(false); + const requestSeqRef = useRef(0); + const latestAppliedSeqRef = useRef(0); + const pendingLoadCountRef = useRef(0); const loadJobs = useCallback(async () => { + const seq = ++requestSeqRef.current; + pendingLoadCountRef.current += 1; try { setIsLoading(true); const data = await api.getJobs(); - setJobs(data.jobs); - setStats(data.byStatus); + if (seq >= latestAppliedSeqRef.current) { + latestAppliedSeqRef.current = seq; + setJobs(data.jobs); + setStats(data.byStatus); + } } catch (error) { const message = error instanceof Error ? error.message : "Failed to load jobs"; toast.error(message); } finally { - setIsLoading(false); + pendingLoadCountRef.current = Math.max(0, pendingLoadCountRef.current - 1); + if (pendingLoadCountRef.current === 0) { + setIsLoading(false); + } } }, []); @@ -47,12 +59,13 @@ export const useOrchestratorData = () => { checkPipelineStatus(); const interval = setInterval(() => { + if (isRefreshPaused) return; loadJobs(); checkPipelineStatus(); }, 10000); return () => clearInterval(interval); - }, [loadJobs, checkPipelineStatus]); + }, [loadJobs, checkPipelineStatus, isRefreshPaused]); return { jobs, @@ -60,6 +73,8 @@ export const useOrchestratorData = () => { isLoading, isPipelineRunning, setIsPipelineRunning, + isRefreshPaused, + setIsRefreshPaused, loadJobs, checkPipelineStatus, };