From 16dd17ebeac22c9906d36ed86c514a0dde3aa17d Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:23:33 +0000 Subject: [PATCH] Fix Tailor CV adding new skills and restore original skills on revert (#190) * Initial commit * refactor slightly * refactor and fix bugs --- .../components/TailoringEditor.test.tsx | 120 ++++ .../discovered-panel/TailorMode.test.tsx | 73 +++ .../client/components/tailoring-utils.test.ts | 48 +- .../src/client/components/tailoring-utils.ts | 41 ++ .../tailoring/TailoringSections.tsx | 571 +++++++++++------- .../tailoring/TailoringWorkspace.tsx | 211 +++++-- .../components/tailoring/useTailoringDraft.ts | 1 + 7 files changed, 806 insertions(+), 259 deletions(-) diff --git a/orchestrator/src/client/components/TailoringEditor.test.tsx b/orchestrator/src/client/components/TailoringEditor.test.tsx index 10aa670..631b6c9 100644 --- a/orchestrator/src/client/components/TailoringEditor.test.tsx +++ b/orchestrator/src/client/components/TailoringEditor.test.tsx @@ -3,6 +3,7 @@ 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 { useProfile } from "../hooks/useProfile"; import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; import { TailoringEditor } from "./TailoringEditor"; @@ -14,6 +15,10 @@ vi.mock("../api", () => ({ getTracerReadiness: vi.fn(), })); +vi.mock("../hooks/useProfile", () => ({ + useProfile: vi.fn(), +})); + vi.mock("sonner", () => ({ toast: { success: vi.fn(), @@ -54,6 +59,32 @@ describe("TailoringEditor", () => { lastSuccessAt: Date.now(), reason: null, }); + vi.mocked(useProfile).mockReturnValue({ + profile: { + basics: { + summary: "Original base summary", + label: "Original base headline", + }, + sections: { + skills: { + items: [ + { + id: "s1", + name: "Backend", + description: "", + level: 0, + keywords: ["Node.js", "TypeScript"], + visible: true, + }, + ], + }, + }, + }, + error: null, + isLoading: false, + personName: "Resume", + refreshProfile: vi.fn(), + }); }); it("does not rehydrate local edits from same-job prop updates", async () => { @@ -277,4 +308,93 @@ describe("TailoringEditor", () => { ), ); }); + + it("supports undo to template and redo to AI draft", async () => { + render(); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + ensureAccordionOpen("Summary"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Original base summary", + ); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Saved summary", + ); + + ensureAccordionOpen("Headline"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[1]); + expect(screen.getByLabelText("Tailored Headline")).toHaveValue( + "Original base headline", + ); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[1]); + expect(screen.getByLabelText("Tailored Headline")).toHaveValue( + "Saved headline", + ); + + ensureAccordionOpen("Tailored Skills"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[2]); + ensureAccordionOpen("Backend"); + expect(screen.getByDisplayValue("Node.js, TypeScript")).toBeInTheDocument(); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[2]); + ensureAccordionOpen("Core"); + expect(screen.getByDisplayValue("React, TypeScript")).toBeInTheDocument(); + }); + + it("resets redo baseline when switching jobs", async () => { + const { rerender } = render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + ensureAccordionOpen("Summary"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Original base summary", + ); + + rerender( + , + ); + + ensureAccordionOpen("Summary"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[0]); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Second job summary", + ); + }); + + it("keeps undo disabled until profile template is loaded", async () => { + vi.mocked(useProfile).mockReturnValue({ + profile: null, + error: null, + isLoading: true, + personName: "Resume", + refreshProfile: vi.fn(), + }); + + render(); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + ensureAccordionOpen("Summary"); + ensureAccordionOpen("Headline"); + ensureAccordionOpen("Tailored Skills"); + + for (const button of screen.getAllByLabelText("Undo to template")) { + expect(button).toBeDisabled(); + } + }); }); diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx index 06474e1..cf21ae0 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx @@ -3,6 +3,7 @@ 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 { useProfile } from "../../hooks/useProfile"; import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness"; import { TailorMode } from "./TailorMode"; @@ -13,6 +14,10 @@ vi.mock("../../api", () => ({ getTracerReadiness: vi.fn(), })); +vi.mock("../../hooks/useProfile", () => ({ + useProfile: vi.fn(), +})); + vi.mock("sonner", () => ({ toast: { success: vi.fn(), @@ -53,6 +58,32 @@ describe("TailorMode", () => { lastSuccessAt: Date.now(), reason: null, }); + vi.mocked(useProfile).mockReturnValue({ + profile: { + basics: { + summary: "Original base summary", + label: "Original base headline", + }, + sections: { + skills: { + items: [ + { + id: "s1", + name: "Backend", + description: "", + level: 0, + keywords: ["Node.js", "TypeScript"], + visible: true, + }, + ], + }, + }, + }, + error: null, + isLoading: false, + personName: "Resume", + refreshProfile: vi.fn(), + }); }); it("does not rehydrate local edits from same-job prop updates", async () => { @@ -270,4 +301,46 @@ describe("TailorMode", () => { expect(screen.getByDisplayValue("Backend")).toBeInTheDocument(); expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument(); }); + + it("supports undo to template and redo to AI draft", async () => { + render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + ensureAccordionOpen("Summary"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Original base summary", + ); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Saved summary", + ); + + ensureAccordionOpen("Headline"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[1]); + expect(screen.getByLabelText("Tailored Headline")).toHaveValue( + "Original base headline", + ); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[1]); + expect(screen.getByLabelText("Tailored Headline")).toHaveValue( + "Saved headline", + ); + + ensureAccordionOpen("Tailored Skills"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[2]); + ensureAccordionOpen("Backend"); + expect(screen.getByDisplayValue("Node.js, TypeScript")).toBeInTheDocument(); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[2]); + ensureAccordionOpen("Core"); + expect(screen.getByDisplayValue("React, TypeScript")).toBeInTheDocument(); + }); }); diff --git a/orchestrator/src/client/components/tailoring-utils.test.ts b/orchestrator/src/client/components/tailoring-utils.test.ts index ed56915..7ccdff2 100644 --- a/orchestrator/src/client/components/tailoring-utils.test.ts +++ b/orchestrator/src/client/components/tailoring-utils.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { parseTailoredSkills } from "./tailoring-utils"; +import { + getOriginalHeadline, + getOriginalSkills, + getOriginalSummary, + parseTailoredSkills, +} from "./tailoring-utils"; describe("parseTailoredSkills", () => { it("parses object-based tailored skills payload", () => { @@ -44,4 +49,45 @@ describe("parseTailoredSkills", () => { [], ); }); + + it("extracts original summary and headline from profile basics", () => { + const profile = { + basics: { + summary: " Base summary ", + label: " Base headline ", + }, + }; + + expect(getOriginalSummary(profile)).toBe("Base summary"); + expect(getOriginalHeadline(profile)).toBe("Base headline"); + }); + + it("extracts original skills from profile skills items", () => { + const profile = { + sections: { + skills: { + items: [ + { + id: "1", + name: "Backend", + description: "", + level: 0, + keywords: [" Node.js ", "TypeScript"], + visible: true, + }, + ], + }, + }, + }; + + expect(getOriginalSkills(profile)).toEqual([ + { name: "Backend", keywords: ["Node.js", "TypeScript"] }, + ]); + }); + + it("returns defaults when profile sections are missing", () => { + expect(getOriginalSummary(null)).toBe(""); + expect(getOriginalHeadline(null)).toBe(""); + expect(getOriginalSkills(null)).toEqual([]); + }); }); diff --git a/orchestrator/src/client/components/tailoring-utils.ts b/orchestrator/src/client/components/tailoring-utils.ts index 4e64692..895dc18 100644 --- a/orchestrator/src/client/components/tailoring-utils.ts +++ b/orchestrator/src/client/components/tailoring-utils.ts @@ -1,3 +1,5 @@ +import type { ResumeProfile } from "@shared/types"; + export interface TailoredSkillGroup { name: string; keywords: string[]; @@ -93,3 +95,42 @@ export function fromEditableSkillGroups( return normalized; } + +export function getOriginalSummary(profile: ResumeProfile | null): string { + if (!profile) return ""; + return profile.basics?.summary?.trim() ?? ""; +} + +export function getOriginalHeadline(profile: ResumeProfile | null): string { + if (!profile) return ""; + return profile.basics?.label?.trim() ?? ""; +} + +export function getOriginalSkills( + profile: ResumeProfile | null, +): TailoredSkillGroup[] { + if (!profile) return []; + + const items = profile.sections?.skills?.items; + if (!Array.isArray(items)) return []; + + const groups: TailoredSkillGroup[] = []; + for (const item of items) { + if (!item || typeof item !== "object") continue; + const name = + typeof item.name === "string" + ? item.name.trim() + : typeof item.description === "string" + ? item.description.trim() + : ""; + const keywordsRaw = Array.isArray(item.keywords) ? item.keywords : []; + const keywords = keywordsRaw + .filter((value: unknown): value is string => typeof value === "string") + .map((value: string) => value.trim()) + .filter(Boolean); + if (!name && keywords.length === 0) continue; + groups.push({ name, keywords }); + } + + return groups; +} diff --git a/orchestrator/src/client/components/tailoring/TailoringSections.tsx b/orchestrator/src/client/components/tailoring/TailoringSections.tsx index c494121..bad5f05 100644 --- a/orchestrator/src/client/components/tailoring/TailoringSections.tsx +++ b/orchestrator/src/client/components/tailoring/TailoringSections.tsx @@ -1,5 +1,5 @@ import type { ResumeProjectCatalogItem } from "@shared/types.js"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus, Redo2, Trash2, Undo2 } from "lucide-react"; import type React from "react"; import { Accordion, @@ -9,6 +9,12 @@ import { } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { ProjectSelector } from "../discovered-panel/ProjectSelector"; import type { EditableSkillGroup } from "../tailoring-utils"; @@ -28,6 +34,19 @@ interface TailoringSectionsProps { disableInputs: boolean; onSummaryChange: (value: string) => void; onHeadlineChange: (value: string) => void; + onUndoSummary: () => void; + onUndoHeadline: () => void; + onUndoSkills: () => void; + onRedoSummary: () => void; + onRedoHeadline: () => void; + onRedoSkills: () => void; + canUndoSummary: boolean; + canUndoHeadline: boolean; + canUndoSkills: boolean; + canRedoSummary: boolean; + canRedoHeadline: boolean; + canRedoSkills: boolean; + undoDisabledReason?: string | null; onDescriptionChange: (value: string) => void; onSkillGroupOpenChange: (value: string) => void; onAddSkillGroup: () => void; @@ -63,6 +82,19 @@ export const TailoringSections: React.FC = ({ disableInputs, onSummaryChange, onHeadlineChange, + onUndoSummary, + onUndoHeadline, + onUndoSkills, + onRedoSummary, + onRedoHeadline, + onRedoSkills, + canUndoSummary, + canUndoHeadline, + canUndoSkills, + canRedoSummary, + canRedoHeadline, + canRedoSkills, + undoDisabledReason = null, onDescriptionChange, onSkillGroupOpenChange, onAddSkillGroup, @@ -73,226 +105,345 @@ export const TailoringSections: React.FC = ({ }) => { const tracerToggleDisabled = disableInputs || (!tracerLinksEnabled && tracerEnableBlocked); + const undoTooltip = "Undo to template"; + const redoTooltip = "Redo to AI draft"; return ( - - - - Job Description - - - - Job Description - - onDescriptionChange(event.target.value)} - placeholder="The raw job description..." - disabled={disableInputs} - /> - - - - - Summary - - - Tailored Summary - - onSummaryChange(event.target.value)} - placeholder="Write a tailored summary for this role, or generate with AI..." - disabled={disableInputs} - /> - - - - - Headline - - - Tailored Headline - - onHeadlineChange(event.target.value)} - placeholder="Write a concise headline tailored to this role..." - disabled={disableInputs} - /> - - - - - - Tailored Skills - - - - - - Add Skill Group - - - - {skillsDraft.length === 0 ? ( - - No skill groups yet. Add one to tailor keywords for this role. - - ) : ( - - {skillsDraft.map((group, index) => ( - - - {group.name.trim() || `Skill Group ${index + 1}`} - - - - - - Category - - - onUpdateSkillGroup( - group.id, - "name", - event.target.value, - ) - } - placeholder="Backend, Frontend, Infrastructure..." - disabled={disableInputs} - /> - - - - - Keywords (comma-separated) - - - onUpdateSkillGroup( - group.id, - "keywordsText", - event.target.value, - ) - } - placeholder="TypeScript, Node.js, REST APIs..." - disabled={disableInputs} - /> - - - - onRemoveSkillGroup(group.id)} - disabled={disableInputs} - > - - Remove - - - - - - ))} - - )} - - - - {!isCatalogLoading && catalog.length > 0 && ( - + + + - Selected Projects + Job Description - + Job Description + + onDescriptionChange(event.target.value)} + placeholder="The raw job description..." disabled={disableInputs} /> - )} - - - Tracer Links - - - - - - onTracerLinksEnabledChange(Boolean(checked)) - } - disabled={tracerToggleDisabled} - /> - - Enable tracer links for this job - + + Summary + + + + + + + + + {undoTooltip} + + + + + + + + {redoTooltip} + + + + Tailored Summary - - {tracerReadinessChecking - ? "Checking tracer-link readiness..." - : "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."} - - {tracerEnableBlockedReason && !tracerLinksEnabled ? ( - - Tracer links are unavailable: {tracerEnableBlockedReason} + onSummaryChange(event.target.value)} + placeholder="Write a tailored summary for this role, or generate with AI..." + disabled={disableInputs} + /> + + + + + Headline + + + + + + + + + {undoTooltip} + + + + + + + + {redoTooltip} + + + + Tailored Headline + + onHeadlineChange(event.target.value)} + placeholder="Write a concise headline tailored to this role..." + disabled={disableInputs} + /> + + + + + + Tailored Skills + + + + + + + + + + {undoTooltip} + + + + + + + + {redoTooltip} + + + + Add Skill Group + + + + {skillsDraft.length === 0 ? ( + + No skill groups yet. Add one to tailor keywords for this role. + + ) : ( + + {skillsDraft.map((group, index) => ( + + + {group.name.trim() || `Skill Group ${index + 1}`} + + + + + + Category + + + onUpdateSkillGroup( + group.id, + "name", + event.target.value, + ) + } + placeholder="Backend, Frontend, Infrastructure..." + disabled={disableInputs} + /> + + + + + Keywords (comma-separated) + + + onUpdateSkillGroup( + group.id, + "keywordsText", + event.target.value, + ) + } + placeholder="TypeScript, Node.js, REST APIs..." + disabled={disableInputs} + /> + + + + onRemoveSkillGroup(group.id)} + disabled={disableInputs} + > + + Remove + + + + + + ))} + + )} + + + + {!isCatalogLoading && catalog.length > 0 && ( + + + Selected Projects + + + + + + )} + + + + Tracer Links + + + + + + onTracerLinksEnabledChange(Boolean(checked)) + } + disabled={tracerToggleDisabled} + /> + + Enable tracer links for this job + + + + {tracerReadinessChecking + ? "Checking tracer-link readiness..." + : "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."} - ) : null} - - - - + {tracerEnableBlockedReason && !tracerLinksEnabled ? ( + + Tracer links are unavailable: {tracerEnableBlockedReason} + + ) : null} + + + + + ); }; diff --git a/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx b/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx index 0faca10..0f9cf23 100644 --- a/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx +++ b/orchestrator/src/client/components/tailoring/TailoringWorkspace.tsx @@ -1,12 +1,23 @@ import type { Job } from "@shared/types.js"; import { ArrowLeft, Check, FileText, Loader2, Sparkles } from "lucide-react"; import type React from "react"; +import type { ComponentProps } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import * as api from "../../api"; +import { useProfile } from "../../hooks/useProfile"; import { useTracerReadiness } from "../../hooks/useTracerReadiness"; +import { + fromEditableSkillGroups, + getOriginalHeadline, + getOriginalSkills, + getOriginalSummary, + parseTailoredSkills, + serializeTailoredSkills, + toEditableSkillGroups, +} from "../tailoring-utils"; import { canFinalizeTailoring } from "./rules"; import { TailoringSections } from "./TailoringSections"; import { useTailoringDraft } from "./useTailoringDraft"; @@ -34,6 +45,22 @@ interface TailoringWorkspaceTailorProps extends TailoringWorkspaceBaseProps { type TailoringWorkspaceProps = | TailoringWorkspaceEditorProps | TailoringWorkspaceTailorProps; +type TailoringSectionsProps = ComponentProps; + +interface TailoringBaseline { + summary: string; + headline: string; + skillsJson: string; +} + +const normalizeSkillsJson = (value: string | null | undefined) => + serializeTailoredSkills(parseTailoredSkills(value)); + +const toBaselineFromJob = (job: Job): TailoringBaseline => ({ + summary: job.tailoredSummary ?? "", + headline: job.tailoredHeadline ?? "", + skillsJson: normalizeSkillsJson(job.tailoredSkills), +}); export const TailoringWorkspace: React.FC = ( props, @@ -55,6 +82,7 @@ export const TailoringWorkspace: React.FC = ( tracerLinksEnabled, setTracerLinksEnabled, skillsDraft, + setSkillsDraft, openSkillGroupId, setOpenSkillGroupId, skillsJson, @@ -73,9 +101,36 @@ export const TailoringWorkspace: React.FC = ( const [isSummarizing, setIsSummarizing] = useState(false); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); const [isGenerating, setIsGenerating] = useState(false); + const { profile, error: profileError } = useProfile(); const { readiness: tracerReadiness, isChecking: isTracerReadinessChecking } = useTracerReadiness(); + const originalValues = useMemo(() => { + const skillsDraft = toEditableSkillGroups(getOriginalSkills(profile)); + return { + summary: getOriginalSummary(profile), + headline: getOriginalHeadline(profile), + skillsDraft, + skillsJson: serializeTailoredSkills(fromEditableSkillGroups(skillsDraft)), + }; + }, [profile]); + const canUseOriginalValues = Boolean(profile) && !profileError; + const [aiBaseline, setAiBaseline] = useState(() => + toBaselineFromJob(props.job), + ); + + useEffect(() => { + setAiBaseline({ + summary: props.job.tailoredSummary ?? "", + headline: props.job.tailoredHeadline ?? "", + skillsJson: normalizeSkillsJson(props.job.tailoredSkills), + }); + }, [ + props.job.tailoredSummary, + props.job.tailoredHeadline, + props.job.tailoredSkills, + ]); + const tracerEnableBlocked = !tracerLinksEnabled && !tracerReadiness?.canEnable; const tracerEnableBlockedReason = @@ -149,6 +204,7 @@ export const TailoringWorkspace: React.FC = ( const updatedJob = await api.summarizeJob(props.job.id, { force: true }); applyIncomingDraft(updatedJob); + setAiBaseline(toBaselineFromJob(updatedJob)); toast.success("AI Summary & Projects generated"); await editorProps.onUpdate(); } catch (error) { @@ -172,6 +228,7 @@ export const TailoringWorkspace: React.FC = ( const updatedJob = await api.summarizeJob(props.job.id, { force: true }); applyIncomingDraft(updatedJob); + setAiBaseline(toBaselineFromJob(updatedJob)); toast.success("Draft generated with AI", { description: "Review and edit before finalizing.", @@ -233,11 +290,115 @@ export const TailoringWorkspace: React.FC = ( tailorProps.onFinalize(); }, [tailorProps, isDirty, persistCurrent]); + const handleUndoSummary = useCallback(() => { + setSummary(originalValues.summary); + }, [originalValues.summary, setSummary]); + + const handleUndoHeadline = useCallback(() => { + setHeadline(originalValues.headline); + }, [originalValues.headline, setHeadline]); + + const handleUndoSkills = useCallback(() => { + setSkillsDraft(originalValues.skillsDraft); + }, [originalValues.skillsDraft, setSkillsDraft]); + + const handleRedoSummary = useCallback(() => { + setSummary(aiBaseline.summary); + }, [aiBaseline.summary, setSummary]); + + const handleRedoHeadline = useCallback(() => { + setHeadline(aiBaseline.headline); + }, [aiBaseline.headline, setHeadline]); + + const handleRedoSkills = useCallback(() => { + setSkillsDraft( + toEditableSkillGroups(parseTailoredSkills(aiBaseline.skillsJson)), + ); + }, [aiBaseline.skillsJson, setSkillsDraft]); + const disableInputs = editorProps ? isSummarizing || isGeneratingPdf || isSaving : isGenerating || Boolean(tailorProps?.isFinalizing) || isSaving; const canFinalize = canFinalizeTailoring(summary); + const tailoringSectionsProps = useMemo( + () => ({ + catalog, + isCatalogLoading, + summary, + headline, + jobDescription, + skillsDraft, + selectedIds, + tracerLinksEnabled, + tracerEnableBlocked, + tracerEnableBlockedReason, + tracerReadinessChecking: isTracerReadinessChecking, + openSkillGroupId, + disableInputs, + onSummaryChange: setSummary, + onHeadlineChange: setHeadline, + onUndoSummary: handleUndoSummary, + onUndoHeadline: handleUndoHeadline, + onUndoSkills: handleUndoSkills, + onRedoSummary: handleRedoSummary, + onRedoHeadline: handleRedoHeadline, + onRedoSkills: handleRedoSkills, + canUndoSummary: + canUseOriginalValues && summary !== originalValues.summary, + canUndoHeadline: + canUseOriginalValues && headline !== originalValues.headline, + canUndoSkills: + canUseOriginalValues && skillsJson !== originalValues.skillsJson, + canRedoSummary: summary !== aiBaseline.summary, + canRedoHeadline: headline !== aiBaseline.headline, + canRedoSkills: skillsJson !== aiBaseline.skillsJson, + undoDisabledReason: canUseOriginalValues + ? null + : "Original base CV unavailable.", + onDescriptionChange: setJobDescription, + onSkillGroupOpenChange: setOpenSkillGroupId, + onAddSkillGroup: handleAddSkillGroup, + onUpdateSkillGroup: handleUpdateSkillGroup, + onRemoveSkillGroup: handleRemoveSkillGroup, + onToggleProject: handleToggleProject, + onTracerLinksEnabledChange: setTracerLinksEnabled, + }), + [ + catalog, + isCatalogLoading, + summary, + headline, + jobDescription, + skillsDraft, + selectedIds, + tracerLinksEnabled, + tracerEnableBlocked, + tracerEnableBlockedReason, + isTracerReadinessChecking, + openSkillGroupId, + disableInputs, + setSummary, + setHeadline, + handleUndoSummary, + handleUndoHeadline, + handleUndoSkills, + handleRedoSummary, + handleRedoHeadline, + handleRedoSkills, + canUseOriginalValues, + originalValues, + skillsJson, + aiBaseline, + setJobDescription, + setOpenSkillGroupId, + handleAddSkillGroup, + handleUpdateSkillGroup, + handleRemoveSkillGroup, + handleToggleProject, + setTracerLinksEnabled, + ], + ); if (editorProps) { return ( @@ -280,30 +441,7 @@ export const TailoringWorkspace: React.FC = ( - + = ( - + diff --git a/orchestrator/src/client/components/tailoring/useTailoringDraft.ts b/orchestrator/src/client/components/tailoring/useTailoringDraft.ts index 8472df8..20a3b85 100644 --- a/orchestrator/src/client/components/tailoring/useTailoringDraft.ts +++ b/orchestrator/src/client/components/tailoring/useTailoringDraft.ts @@ -224,6 +224,7 @@ export function useTailoringDraft({ selectedIds, selectedIdsCsv, skillsDraft, + setSkillsDraft, openSkillGroupId, setOpenSkillGroupId, skillsJson,
- {tracerReadinessChecking - ? "Checking tracer-link readiness..." - : "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."} -
- Tracer links are unavailable: {tracerEnableBlockedReason} + onSummaryChange(event.target.value)} + placeholder="Write a tailored summary for this role, or generate with AI..." + disabled={disableInputs} + /> +
+ {tracerReadinessChecking + ? "Checking tracer-link readiness..." + : "When enabled, outgoing resume links are rewritten to JobOps tracer links on the next PDF generation. Existing PDFs are unchanged."}
+ Tracer links are unavailable: {tracerEnableBlockedReason} +