diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx index a2cc16e..7e4e7e8 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx @@ -190,45 +190,6 @@ describe("TailorMode", () => { ); }); - it("autosaves headline and skills in update payload", async () => { - vi.mocked(api.updateJob).mockResolvedValue({} as Job); - - render( - , - ); - await waitFor(() => - expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), - ); - ensureAccordionOpen("Headline"); - ensureAccordionOpen("Tailored Skills"); - ensureAccordionOpen("Core"); - - fireEvent.change(screen.getByLabelText("Tailored Headline"), { - target: { value: "Updated headline" }, - }); - fireEvent.change(screen.getByLabelText("Keywords (comma-separated)"), { - target: { value: "Node.js, TypeScript" }, - }); - - await waitFor( - () => - expect(api.updateJob).toHaveBeenCalledWith( - "job-1", - expect.objectContaining({ - tailoredHeadline: "Updated headline", - tailoredSkills: - '[{"name":"Core","keywords":["Node.js","TypeScript"]}]', - }), - ), - { timeout: 3500 }, - ); - }); - it("hydrates headline and skills after AI draft generation", async () => { vi.mocked(api.summarizeJob).mockResolvedValueOnce({ ...createJob(), diff --git a/orchestrator/src/client/components/tailoring/TailoringSections.tsx b/orchestrator/src/client/components/tailoring/TailoringSections.tsx index d2fc3f8..5a03541 100644 --- a/orchestrator/src/client/components/tailoring/TailoringSections.tsx +++ b/orchestrator/src/client/components/tailoring/TailoringSections.tsx @@ -10,9 +10,6 @@ import { import { Button } from "@/components/ui/button"; import { ProjectSelector } from "../discovered-panel/ProjectSelector"; import type { EditableSkillGroup } from "../tailoring-utils"; -import type { TailoringActiveField } from "./useTailoringDraft"; - -type FocusableField = Exclude; interface TailoringSectionsProps { catalog: ResumeProjectCatalogItem[]; @@ -35,8 +32,6 @@ interface TailoringSectionsProps { ) => void; onRemoveSkillGroup: (id: string) => void; onToggleProject: (id: string) => void; - onFieldFocus: (field: FocusableField) => void; - onFieldBlur: (field: FocusableField) => void; } const sectionClass = "rounded-lg border border-border/60 bg-muted/20 px-0"; @@ -62,8 +57,6 @@ export const TailoringSections: React.FC = ({ onUpdateSkillGroup, onRemoveSkillGroup, onToggleProject, - onFieldFocus, - onFieldBlur, }) => { return ( @@ -80,8 +73,6 @@ export const TailoringSections: React.FC = ({ className={`${inputClass} min-h-[120px] max-h-[250px]`} value={jobDescription} onChange={(event) => onDescriptionChange(event.target.value)} - onFocus={() => onFieldFocus("description")} - onBlur={() => onFieldBlur("description")} placeholder="The raw job description..." disabled={disableInputs} /> @@ -99,8 +90,6 @@ export const TailoringSections: React.FC = ({ className={`${inputClass} min-h-[120px]`} value={summary} onChange={(event) => onSummaryChange(event.target.value)} - onFocus={() => onFieldFocus("summary")} - onBlur={() => onFieldBlur("summary")} placeholder="Write a tailored summary for this role, or generate with AI..." disabled={disableInputs} /> @@ -119,8 +108,6 @@ export const TailoringSections: React.FC = ({ className={inputClass} value={headline} onChange={(event) => onHeadlineChange(event.target.value)} - onFocus={() => onFieldFocus("headline")} - onBlur={() => onFieldBlur("headline")} placeholder="Write a concise headline tailored to this role..." disabled={disableInputs} /> @@ -188,8 +175,6 @@ export const TailoringSections: React.FC = ({ event.target.value, ) } - onFocus={() => onFieldFocus("skills")} - onBlur={() => onFieldBlur("skills")} placeholder="Backend, Frontend, Infrastructure..." disabled={disableInputs} /> @@ -213,8 +198,6 @@ export const TailoringSections: React.FC = ({ event.target.value, ) } - onFocus={() => onFieldFocus("skills")} - onBlur={() => onFieldBlur("skills")} placeholder="TypeScript, Node.js, REST APIs..." disabled={disableInputs} /> diff --git a/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx b/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx index 6dbfd50..408dcc4 100644 --- a/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx +++ b/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx @@ -54,8 +54,6 @@ export const TailoringWorkspace: React.FC = ( setOpenSkillGroupId, skillsJson, isDirty, - setActiveField, - markCurrentAsSaved, applyIncomingDraft, handleToggleProject, handleAddSkillGroup, @@ -70,9 +68,6 @@ export const TailoringWorkspace: React.FC = ( const [isSummarizing, setIsSummarizing] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isGenerating, setIsGenerating] = useState(false); - const [draftStatus, setDraftStatus] = useState< - "unsaved" | "saving" | "saved" - >("saved"); const savePayload = useMemo( () => ({ @@ -86,38 +81,14 @@ export const TailoringWorkspace: React.FC = ( ); const persistCurrent = useCallback(async () => { - await api.updateJob(props.job.id, savePayload); - markCurrentAsSaved(); - if (tailorProps) { - setDraftStatus("saved"); - } - }, [props.job.id, savePayload, markCurrentAsSaved, tailorProps]); + const updatedJob = await api.updateJob(props.job.id, savePayload); + applyIncomingDraft(updatedJob); + }, [props.job.id, savePayload, applyIncomingDraft]); - useEffect(() => { - if (!tailorProps) return; - if (isDirty && draftStatus === "saved") { - setDraftStatus("unsaved"); - } - if (!isDirty && draftStatus === "unsaved") { - setDraftStatus("saved"); - } - }, [tailorProps, isDirty, draftStatus]); - - useEffect(() => { - if (!tailorProps) return; - if (!isDirty || draftStatus !== "unsaved") return; - - const timeout = setTimeout(async () => { - try { - setDraftStatus("saving"); - await persistCurrent(); - } catch { - setDraftStatus("unsaved"); - } - }, 1500); - - return () => clearTimeout(timeout); - }, [tailorProps, isDirty, draftStatus, persistCurrent]); + // Note: Auto-save removed. + // Editor mode: user must explicitly save via the "Save Selection" button to persist changes. + // Tailor mode: there is no explicit save action; changes only persist when the user finalizes + // or otherwise completes the tailoring flow. This prevents race conditions and simplifies state. const saveChanges = useCallback( async ({ showToast = true }: { showToast?: boolean } = {}) => { @@ -125,8 +96,8 @@ export const TailoringWorkspace: React.FC = ( try { setIsSaving(true); - await api.updateJob(props.job.id, savePayload); - markCurrentAsSaved(); + const updatedJob = await api.updateJob(props.job.id, savePayload); + applyIncomingDraft(updatedJob); if (showToast) toast.success("Changes saved"); await editorProps.onUpdate(); } catch (error) { @@ -136,7 +107,7 @@ export const TailoringWorkspace: React.FC = ( setIsSaving(false); } }, - [editorProps, props.job.id, savePayload, markCurrentAsSaved], + [editorProps, props.job.id, savePayload, applyIncomingDraft], ); useEffect(() => { @@ -144,13 +115,6 @@ export const TailoringWorkspace: React.FC = ( editorProps.onRegisterSave(() => saveChanges({ showToast: false })); }, [editorProps, saveChanges]); - const handleFieldBlur = useCallback( - (field: "summary" | "headline" | "description" | "skills") => { - setActiveField((prev) => (prev === field ? null : prev)); - }, - [setActiveField], - ); - const handleSummarizeEditor = useCallback(async () => { if (!editorProps) return; @@ -185,7 +149,6 @@ export const TailoringWorkspace: React.FC = ( const updatedJob = await api.summarizeJob(props.job.id, { force: true }); applyIncomingDraft(updatedJob); - setDraftStatus("saved"); toast.success("Draft generated with AI", { description: "Review and edit before finalizing.", @@ -303,8 +266,6 @@ export const TailoringWorkspace: React.FC = ( onUpdateSkillGroup={handleUpdateSkillGroup} onRemoveSkillGroup={handleRemoveSkillGroup} onToggleProject={handleToggleProject} - onFieldFocus={setActiveField} - onFieldBlur={handleFieldBlur} />
@@ -342,24 +303,6 @@ export const TailoringWorkspace: React.FC = ( Back to overview - -
- {draftStatus === "saving" && ( - <> - - Saving... - - )} - {draftStatus === "saved" && !isDirty && ( - <> - - Saved - - )} - {draftStatus === "unsaved" && ( - Unsaved changes - )} -
@@ -409,8 +352,6 @@ export const TailoringWorkspace: React.FC = ( onUpdateSkillGroup={handleUpdateSkillGroup} onRemoveSkillGroup={handleRemoveSkillGroup} onToggleProject={handleToggleProject} - onFieldFocus={setActiveField} - onFieldBlur={handleFieldBlur} />
diff --git a/orchestrator/src/client/components/tailoring/useTailoringDraft.ts b/orchestrator/src/client/components/tailoring/useTailoringDraft.ts index 5363bfc..676e8a3 100644 --- a/orchestrator/src/client/components/tailoring/useTailoringDraft.ts +++ b/orchestrator/src/client/components/tailoring/useTailoringDraft.ts @@ -10,13 +10,6 @@ import { toEditableSkillGroups, } from "../tailoring-utils"; -export type TailoringActiveField = - | "summary" - | "headline" - | "description" - | "skills" - | null; - const parseSelectedIds = (value: string | null | undefined) => new Set(value?.split(",").filter(Boolean) ?? []); @@ -87,8 +80,8 @@ export function useTailoringDraft({ serializeTailoredSkills(parseTailoredSkills(job.tailoredSkills)), ); - const [activeField, setActiveField] = useState(null); const lastJobIdRef = useRef(job.id); + const jobRef = useRef(job); const skillsJson = useMemo( () => serializeTailoredSkills(fromEditableSkillGroups(skillsDraft)), @@ -119,42 +112,6 @@ export function useTailoringDraft({ savedSelectedIds, ]); - const syncSavedSnapshot = useCallback( - ( - nextSummary: string, - nextHeadline: string, - nextDescription: string, - nextSelectedIds: Set, - nextSkillsDraft: EditableSkillGroup[], - ) => { - setSavedSummary(nextSummary); - setSavedHeadline(nextHeadline); - setSavedDescription(nextDescription); - setSavedSelectedIds(new Set(nextSelectedIds)); - setSavedSkillsJson( - serializeTailoredSkills(fromEditableSkillGroups(nextSkillsDraft)), - ); - }, - [], - ); - - const markCurrentAsSaved = useCallback(() => { - syncSavedSnapshot( - summary, - headline, - jobDescription, - selectedIds, - skillsDraft, - ); - }, [ - syncSavedSnapshot, - summary, - headline, - jobDescription, - selectedIds, - skillsDraft, - ]); - const applyIncomingDraft = useCallback((incomingJob: Job) => { const next = parseIncomingDraft(incomingJob); setSummary(next.summary); @@ -184,28 +141,18 @@ export function useTailoringDraft({ .catch(() => setCatalog([])); }, []); + useEffect(() => { + jobRef.current = job; + }, [job]); + + // Only sync when job ID changes (user switched to a different job) + // User edits persist until explicitly saved - no auto-sync from server useEffect(() => { if (job.id !== lastJobIdRef.current) { lastJobIdRef.current = job.id; - applyIncomingDraft(job); - return; + applyIncomingDraft(jobRef.current); } - - if (isDirty || activeField !== null) return; - - applyIncomingDraft(job); - }, [ - job, - job.id, - job.tailoredSummary, - job.tailoredHeadline, - job.tailoredSkills, - job.jobDescription, - job.selectedProjectIds, - isDirty, - activeField, - applyIncomingDraft, - ]); + }, [job.id, applyIncomingDraft]); useEffect(() => { if ( @@ -264,11 +211,7 @@ export function useTailoringDraft({ setOpenSkillGroupId, skillsJson, isDirty, - activeField, - setActiveField, - markCurrentAsSaved, applyIncomingDraft, - syncSavedSnapshot, handleToggleProject, handleAddSkillGroup, handleUpdateSkillGroup,