Inputs getting unset fix (#91)

* codex 5.3 one shot

* prevent cursor changing position
This commit is contained in:
Shaheer Sarfaraz 2026-02-05 19:43:10 +00:00 committed by GitHub
parent 6353a23f6f
commit 0e6f0bcf88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 784 additions and 177 deletions

View File

@ -52,6 +52,7 @@ interface ReadyPanelProps {
job: Job | null;
onJobUpdated: () => void | Promise<void>;
onJobMoved: (jobId: string) => void;
onTailoringDirtyChange?: (isDirty: boolean) => void;
}
const safeFilenamePart = (value: string | null | undefined) =>
@ -61,6 +62,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
job,
onJobUpdated,
onJobMoved,
onTailoringDirtyChange,
}) => {
const [mode, setMode] = useState<PanelMode>("ready");
const [isMarkingApplied, setIsMarkingApplied] = useState(false);
@ -84,7 +86,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
// 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<ReadyPanelProps> = ({
onFinalize={handleTailorFinalize}
isFinalizing={isRegenerating}
variant="ready"
onDirtyChange={onTailoringDirtyChange}
/>
);
}

View File

@ -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> = {}): 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(
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
target: { value: "Local draft" },
});
rerender(
<TailoringEditor
job={createJob({ tailoredSummary: "Older server value" })}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft");
});
it("resets local state when job id changes", async () => {
const { rerender } = render(
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
target: { value: "Local draft" },
});
rerender(
<TailoringEditor
job={createJob({
id: "job-2",
tailoredSummary: "New job summary",
jobDescription: "New job description",
selectedProjectIds: "",
})}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("New job summary");
});
it("emits dirty state changes", async () => {
const onDirtyChange = vi.fn();
render(
<TailoringEditor
job={createJob()}
onUpdate={vi.fn()}
onDirtyChange={onDirtyChange}
/>,
);
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(
<TailoringEditor job={createJob()} onUpdate={vi.fn()} />,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
const summary = screen.getByLabelText("Tailored Summary");
fireEvent.focus(summary);
rerender(
<TailoringEditor
job={createJob({ tailoredSummary: "Incoming from poll" })}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Saved summary",
);
});
});

View File

@ -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<boolean>;
}
const parseSelectedIds = (value: string | null | undefined) =>
new Set(value?.split(",").filter(Boolean) ?? []);
const hasSelectionDiff = (current: Set<string>, saved: Set<string>) => {
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<TailoringEditorProps> = ({
job,
onUpdate,
@ -31,52 +42,88 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds),
);
const [savedSummary, setSavedSummary] = useState(job.tailoredSummary || "");
const [savedDescription, setSavedDescription] = useState(job.jobDescription || "");
const [savedSelectedIds, setSavedSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds),
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<string>,
) => {
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<TailoringEditorProps> = ({
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<TailoringEditorProps> = ({
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<TailoringEditorProps> = ({
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<TailoringEditorProps> = ({
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<TailoringEditorProps> = ({
}
};
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<TailoringEditorProps> = ({
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..."
/>
</div>
@ -226,6 +278,10 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
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..."
/>
</div>

View File

@ -15,12 +15,14 @@ interface DiscoveredPanelProps {
job: Job | null;
onJobUpdated: () => void | Promise<void>;
onJobMoved: (jobId: string) => void;
onTailoringDirtyChange?: (isDirty: boolean) => void;
}
export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
job,
onJobUpdated,
onJobMoved,
onTailoringDirtyChange,
}) => {
const [mode, setMode] = useState<PanelMode>("decide");
const [isSkipping, setIsSkipping] = useState(false);
@ -31,7 +33,18 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
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<DiscoveredPanelProps> = ({
onBack={() => setMode("decide")}
onFinalize={handleFinalize}
isFinalizing={isFinalizing}
onDirtyChange={onTailoringDirtyChange}
/>
)}
</div>

View File

@ -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> = {}): 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(
<TailorMode
job={createJob()}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
target: { value: "Local draft" },
});
rerender(
<TailorMode
job={createJob({ tailoredSummary: "Older server value" })}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue("Local draft");
});
it("resets local state when job id changes", async () => {
const { rerender } = render(
<TailorMode
job={createJob()}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
fireEvent.change(screen.getByLabelText("Tailored Summary"), {
target: { value: "Local draft" },
});
rerender(
<TailorMode
job={createJob({
id: "job-2",
tailoredSummary: "New job summary",
jobDescription: "New job description",
selectedProjectIds: "",
})}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
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(
<TailorMode
job={createJob()}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
await waitFor(() =>
expect(api.getResumeProjectsCatalog).toHaveBeenCalled(),
);
const summary = screen.getByLabelText("Tailored Summary");
fireEvent.focus(summary);
rerender(
<TailorMode
job={createJob({ tailoredSummary: "Incoming from poll" })}
onBack={vi.fn()}
onFinalize={vi.fn()}
isFinalizing={false}
/>,
);
expect(screen.getByLabelText("Tailored Summary")).toHaveValue(
"Saved summary",
);
});
});

View File

@ -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<string>, saved: Set<string>) => {
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<TailorModeProps> = ({
job,
onBack,
onFinalize,
isFinalizing,
onDirtyChange,
variant = "discovered",
}) => {
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
const [summary, setSummary] = useState(job.tailoredSummary || "");
const [jobDescription, setJobDescription] = useState(
job.jobDescription || "",
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
parseSelectedIds(job.selectedProjectIds),
);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
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<Set<string>>(() =>
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<string>,
) => {
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<TailorModeProps> = ({
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<TailorModeProps> = ({
};
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<TailorModeProps> = ({
Saving...
</>
)}
{draftStatus === "saved" && !hasChanges && (
{draftStatus === "saved" && !isDirty && (
<>
<Check className="h-3 w-3 text-emerald-400" />
Saved
@ -260,6 +312,12 @@ export const TailorMode: React.FC<TailorModeProps> = ({
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<TailorModeProps> = ({
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}
/>

View File

@ -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}
/>
</div>
)}
@ -381,6 +383,7 @@ export const OrchestratorPage: React.FC = () => {
selectedJob={selectedJob}
onSelectJobId={handleSelectJobId}
onJobUpdated={loadJobs}
onPauseRefreshChange={setIsRefreshPaused}
/>
</div>
</DrawerContent>

View File

@ -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: () => <div data-testid="tailoring-editor" />,
TailoringEditor: ({
onDirtyChange,
}: {
onDirtyChange?: (isDirty: boolean) => void;
}) => (
<div data-testid="tailoring-editor">
<button type="button" onClick={() => onDirtyChange?.(true)}>
Mark tailoring dirty
</button>
<button type="button" onClick={() => onDirtyChange?.(false)}>
Mark tailoring clean
</button>
</div>
),
}));
vi.mock("@/lib/utils", async (importOriginal) => {
@ -150,52 +163,62 @@ const createJob = (overrides: Partial<Job> = {}): Job => ({
...overrides,
});
const renderJobDetailPanel = async (
props: React.ComponentProps<typeof JobDetailPanel>,
) => {
const rendered = render(<JobDetailPanel {...props} />);
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(
<JobDetailPanel
activeTab="discovered"
activeJobs={[job]}
selectedJob={job}
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
/>,
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(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={null}
onSelectJobId={vi.fn()}
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
/>,
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(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={createJob({
it("renders a stripped description preview for html content", async () => {
await renderJobDetailPanel(
{
activeTab: "all",
activeJobs: [],
selectedJob: createJob({
jobDescription: "<p>Hello <strong>world</strong></p>",
})}
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(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={createJob({ jobDescription: "Original" })}
onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated}
/>,
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(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={createJob({ status: "ready" })}
onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated}
/>,
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(
<JobDetailPanel
activeTab="all"
activeJobs={[]}
selectedJob={createJob({ status: "ready" })}
onSelectJobId={vi.fn()}
onJobUpdated={onJobUpdated}
/>,
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);
});
});

View File

@ -50,6 +50,7 @@ interface JobDetailPanelProps {
selectedJob: Job | null;
onSelectJobId: (jobId: string | null) => void;
onJobUpdated: () => Promise<void>;
onPauseRefreshChange?: (paused: boolean) => void;
}
export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
@ -58,6 +59,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
selectedJob,
onSelectJobId,
onJobUpdated,
onPauseRefreshChange,
}) => {
const [detailTab, setDetailTab] = useState<
"overview" | "tailoring" | "description"
@ -71,10 +73,23 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
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<JobDetailPanelProps> = ({
job={selectedJob}
onJobUpdated={onJobUpdated}
onJobMoved={handleJobMoved}
onTailoringDirtyChange={handleTailoringDirtyChange}
/>
);
}
@ -285,6 +301,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
job={selectedJob}
onJobUpdated={onJobUpdated}
onJobMoved={handleJobMoved}
onTailoringDirtyChange={handleTailoringDirtyChange}
/>
);
}
@ -534,7 +551,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<TailoringEditor
job={selectedJob}
onUpdate={onJobUpdated}
onDirtyChange={setHasUnsavedTailoring}
onDirtyChange={handleTailoringDirtyChange}
onRegisterSave={(save) => {
saveTailoringRef.current = save;
}}

View File

@ -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<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
};
const deferred = <T,>(): Deferred<T> => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<any>();
const second = deferred<any>();
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,
);
});
});

View File

@ -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<Record<JobStatus, number>>(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,
};