diff --git a/orchestrator/src/client/components/CoverLetterDisplay.tsx b/orchestrator/src/client/components/CoverLetterDisplay.tsx index 72fc9e6..959f5a7 100644 --- a/orchestrator/src/client/components/CoverLetterDisplay.tsx +++ b/orchestrator/src/client/components/CoverLetterDisplay.tsx @@ -1,17 +1,23 @@ import type { Job } from "@shared/types.js"; -import { Check, Copy, FileText } from "lucide-react"; +import { Check, Copy, FileText, Loader2, RefreshCw } from "lucide-react"; import type React from "react"; import { useCallback, useState } from "react"; +import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; interface CoverLetterDisplayProps { job: Job; className?: string; + /** When set, shows Generate (no letter) or Regenerate (has letter) instead of hiding entirely. */ + onRequestGenerate?: () => void | Promise; + generateInFlight?: boolean; } export const CoverLetterDisplay: React.FC = ({ job, className, + onRequestGenerate, + generateInFlight = false, }) => { const [copied, setCopied] = useState(false); const [expanded, setExpanded] = useState(false); @@ -23,7 +29,54 @@ export const CoverLetterDisplay: React.FC = ({ setTimeout(() => setCopied(false), 2000); }, [job.coverLetter]); - if (!job.coverLetter) return null; + const canRunGenerate = + Boolean(onRequestGenerate) && job.status !== "processing"; + const showEmptyState = !job.coverLetter && onRequestGenerate; + + if (!job.coverLetter && !onRequestGenerate) { + return null; + } + + if (showEmptyState) { + return ( +
+
+
+ + Cover letter +
+

+ Saved on this job after you generate it (Overview here, or Ready + tab). Use Copy to paste into the employer's application form. +

+ +
+
+ ); + } + + if (!job.coverLetter) { + return null; + } const lines = job.coverLetter.split("\n"); const isLong = lines.length > 8; @@ -32,26 +85,43 @@ export const CoverLetterDisplay: React.FC = ({ return (
-
+
- Cover Letter + Cover letter
- )} - + +

{displayText} diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 030fe8d..1cd35b3 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -45,9 +45,11 @@ import { useMarkAsAppliedMutation, useSkipJobMutation, } from "../hooks/queries/useJobMutations"; +import { useCoverLetterGeneration } from "../hooks/useCoverLetterGeneration"; import { useProfile } from "../hooks/useProfile"; import { useRescoreJob } from "../hooks/useRescoreJob"; import { FitAssessment, JobHeader, TailoredSummary } from "."; +import { CoverLetterDisplay } from "./CoverLetterDisplay"; import { TailorMode } from "./discovered-panel/TailorMode"; import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer"; import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer"; @@ -86,6 +88,8 @@ export const ReadyPanel: React.FC = ({ const previousJobIdRef = useRef(null); const markAsAppliedMutation = useMarkAsAppliedMutation(); const skipJobMutation = useSkipJobMutation(); + const { generateForJob, inFlight: coverLetterGenerating } = + useCoverLetterGeneration(onJobUpdated); const { personName } = useProfile(); @@ -425,6 +429,11 @@ export const ReadyPanel: React.FC = ({

+ generateForJob(job.id)} + generateInFlight={coverLetterGenerating} + /> {googleDorks.length > 0 ? ( diff --git a/orchestrator/src/client/hooks/useCoverLetterGeneration.ts b/orchestrator/src/client/hooks/useCoverLetterGeneration.ts new file mode 100644 index 0000000..5bd4c62 --- /dev/null +++ b/orchestrator/src/client/hooks/useCoverLetterGeneration.ts @@ -0,0 +1,37 @@ +import * as api from "@client/api"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; + +export function useCoverLetterGeneration( + onJobUpdated: () => void | Promise, +) { + const [inFlight, setInFlight] = useState(false); + + const generateForJob = useCallback( + async (jobId: string) => { + setInFlight(true); + try { + const res = await api.runJobAction({ + action: "generate_cover_letter", + jobIds: [jobId], + }); + const r = res.results.find((entry) => entry.jobId === jobId); + if (!r?.ok) { + toast.error(r?.error.message ?? "Could not generate cover letter"); + return; + } + toast.success("Cover letter saved on this job"); + await onJobUpdated(); + } catch (e) { + toast.error( + e instanceof Error ? e.message : "Could not generate cover letter", + ); + } finally { + setInFlight(false); + } + }, + [onJobUpdated], + ); + + return { generateForJob, inFlight }; +} diff --git a/orchestrator/src/client/pages/orchestrator/FloatingJobActionsBar.tsx b/orchestrator/src/client/pages/orchestrator/FloatingJobActionsBar.tsx index 7e72e80..c48031c 100644 --- a/orchestrator/src/client/pages/orchestrator/FloatingJobActionsBar.tsx +++ b/orchestrator/src/client/pages/orchestrator/FloatingJobActionsBar.tsx @@ -88,6 +88,7 @@ export const FloatingJobActionsBar: React.FC = ({ className="w-full sm:w-auto" disabled={jobActionInFlight} onClick={onGenerateCoverLetter} + title="Generates for all selected jobs. You can also open one job → Overview (or Ready tab) → Cover letter." > Cover letter diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index 2b11df7..049cb77 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -13,6 +13,7 @@ import { useMarkAsAppliedMutation, useSkipJobMutation, } from "@client/hooks/queries/useJobMutations"; +import { useCoverLetterGeneration } from "@client/hooks/useCoverLetterGeneration"; import { useProfile } from "@client/hooks/useProfile"; import { useSettings } from "@client/hooks/useSettings"; import type { Job, JobListItem } from "@shared/types.js"; @@ -81,6 +82,8 @@ export const JobDetailPanel: React.FC = ({ const previousSelectedJobIdRef = useRef(null); const markAsAppliedMutation = useMarkAsAppliedMutation(); const skipJobMutation = useSkipJobMutation(); + const { generateForJob, inFlight: coverLetterGenerating } = + useCoverLetterGeneration(onJobUpdated); const { personName } = useProfile(); const { renderMarkdownInJobDescriptions } = useSettings(); @@ -540,6 +543,19 @@ export const JobDetailPanel: React.FC = ({ Edit details + void generateForJob(selectedJob.id)} + disabled={ + selectedJob.status === "processing" || coverLetterGenerating + } + > + + {coverLetterGenerating + ? "Cover letter…" + : selectedJob.coverLetter + ? "Regenerate cover letter" + : "Generate cover letter"} + void handleCopyInfo()}> Copy info @@ -602,7 +618,11 @@ export const JobDetailPanel: React.FC = ({ - + generateForJob(selectedJob.id)} + generateInFlight={coverLetterGenerating} + />