Make cover letter discoverable: Overview, Ready panel, ⋯ menu

- Always show Cover letter card with Generate when empty; Regenerate + Copy when present
- Shared useCoverLetterGeneration hook calling runJobAction(generate_cover_letter)
- Ready tab previously had no cover letter UI; add same block under Fit Assessment
- Floating bar Cover letter button tooltip points to Overview/Ready
- Job detail ⋯ menu: Generate / Regenerate cover letter

Made-with: Cursor
This commit is contained in:
ilia 2026-04-05 21:53:27 -04:00
parent 54adc66e73
commit 6b26f70ef7
5 changed files with 156 additions and 19 deletions

View File

@ -1,17 +1,23 @@
import type { Job } from "@shared/types.js"; 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 type React from "react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface CoverLetterDisplayProps { interface CoverLetterDisplayProps {
job: Job; job: Job;
className?: string; className?: string;
/** When set, shows Generate (no letter) or Regenerate (has letter) instead of hiding entirely. */
onRequestGenerate?: () => void | Promise<void>;
generateInFlight?: boolean;
} }
export const CoverLetterDisplay: React.FC<CoverLetterDisplayProps> = ({ export const CoverLetterDisplay: React.FC<CoverLetterDisplayProps> = ({
job, job,
className, className,
onRequestGenerate,
generateInFlight = false,
}) => { }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -23,7 +29,54 @@ export const CoverLetterDisplay: React.FC<CoverLetterDisplayProps> = ({
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}, [job.coverLetter]); }, [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 (
<div className={cn("space-y-1", className)}>
<div className="rounded-lg border border-violet-500/20 bg-violet-500/5 px-3 py-2.5">
<div className="text-[11px] font-medium uppercase tracking-wide text-violet-600/80 dark:text-violet-400/80 flex items-center gap-1.5 mb-1.5">
<FileText className="h-3 w-3" />
Cover letter
</div>
<p className="text-xs text-muted-foreground leading-relaxed mb-2">
Saved on this job after you generate it (Overview here, or Ready
tab). Use Copy to paste into the employer&apos;s application form.
</p>
<Button
type="button"
size="sm"
variant="secondary"
className="h-8 text-xs"
disabled={!canRunGenerate || generateInFlight}
onClick={() => void onRequestGenerate?.()}
>
{generateInFlight ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
Generating
</>
) : (
<>
<FileText className="h-3.5 w-3.5 mr-1.5" />
Generate cover letter
</>
)}
</Button>
</div>
</div>
);
}
if (!job.coverLetter) {
return null;
}
const lines = job.coverLetter.split("\n"); const lines = job.coverLetter.split("\n");
const isLong = lines.length > 8; const isLong = lines.length > 8;
@ -32,26 +85,43 @@ export const CoverLetterDisplay: React.FC<CoverLetterDisplayProps> = ({
return ( return (
<div className={cn("space-y-1", className)}> <div className={cn("space-y-1", className)}>
<div className="rounded-lg border border-violet-500/20 bg-violet-500/5 px-3 py-2.5"> <div className="rounded-lg border border-violet-500/20 bg-violet-500/5 px-3 py-2.5">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5 gap-2 flex-wrap">
<div className="text-[11px] font-medium uppercase tracking-wide text-violet-600/80 dark:text-violet-400/80 flex items-center gap-1.5"> <div className="text-[11px] font-medium uppercase tracking-wide text-violet-600/80 dark:text-violet-400/80 flex items-center gap-1.5">
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3" />
Cover Letter Cover letter
</div> </div>
<button <div className="flex items-center gap-2">
type="button" {onRequestGenerate && (
onClick={handleCopy} <button
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors" type="button"
> onClick={() => void onRequestGenerate()}
{copied ? ( disabled={!canRunGenerate || generateInFlight}
<> className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors disabled:opacity-50"
<Check className="h-3 w-3" /> Copied >
</> {generateInFlight ? (
) : ( <Loader2 className="h-3 w-3 animate-spin" />
<> ) : (
<Copy className="h-3 w-3" /> Copy <RefreshCw className="h-3 w-3" />
</> )}
Regenerate
</button>
)} )}
</button> <button
type="button"
onClick={handleCopy}
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
>
{copied ? (
<>
<Check className="h-3 w-3" /> Copied
</>
) : (
<>
<Copy className="h-3 w-3" /> Copy
</>
)}
</button>
</div>
</div> </div>
<p className="text-xs text-foreground/85 leading-relaxed whitespace-pre-wrap"> <p className="text-xs text-foreground/85 leading-relaxed whitespace-pre-wrap">
{displayText} {displayText}

View File

@ -45,9 +45,11 @@ import {
useMarkAsAppliedMutation, useMarkAsAppliedMutation,
useSkipJobMutation, useSkipJobMutation,
} from "../hooks/queries/useJobMutations"; } from "../hooks/queries/useJobMutations";
import { useCoverLetterGeneration } from "../hooks/useCoverLetterGeneration";
import { useProfile } from "../hooks/useProfile"; import { useProfile } from "../hooks/useProfile";
import { useRescoreJob } from "../hooks/useRescoreJob"; import { useRescoreJob } from "../hooks/useRescoreJob";
import { FitAssessment, JobHeader, TailoredSummary } from "."; import { FitAssessment, JobHeader, TailoredSummary } from ".";
import { CoverLetterDisplay } from "./CoverLetterDisplay";
import { TailorMode } from "./discovered-panel/TailorMode"; import { TailorMode } from "./discovered-panel/TailorMode";
import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer"; import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer";
import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer"; import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer";
@ -86,6 +88,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
const previousJobIdRef = useRef<string | null>(null); const previousJobIdRef = useRef<string | null>(null);
const markAsAppliedMutation = useMarkAsAppliedMutation(); const markAsAppliedMutation = useMarkAsAppliedMutation();
const skipJobMutation = useSkipJobMutation(); const skipJobMutation = useSkipJobMutation();
const { generateForJob, inFlight: coverLetterGenerating } =
useCoverLetterGeneration(onJobUpdated);
const { personName } = useProfile(); const { personName } = useProfile();
@ -425,6 +429,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
<div className="flex-1 py-4 space-y-4"> <div className="flex-1 py-4 space-y-4">
<div className="space-y-3"> <div className="space-y-3">
<FitAssessment job={job} /> <FitAssessment job={job} />
<CoverLetterDisplay
job={job}
onRequestGenerate={() => generateForJob(job.id)}
generateInFlight={coverLetterGenerating}
/>
<TailoredSummary job={job} /> <TailoredSummary job={job} />
{googleDorks.length > 0 ? ( {googleDorks.length > 0 ? (

View File

@ -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<void>,
) {
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 };
}

View File

@ -88,6 +88,7 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
className="w-full sm:w-auto" className="w-full sm:w-auto"
disabled={jobActionInFlight} disabled={jobActionInFlight}
onClick={onGenerateCoverLetter} onClick={onGenerateCoverLetter}
title="Generates for all selected jobs. You can also open one job → Overview (or Ready tab) → Cover letter."
> >
Cover letter Cover letter
</Button> </Button>

View File

@ -13,6 +13,7 @@ import {
useMarkAsAppliedMutation, useMarkAsAppliedMutation,
useSkipJobMutation, useSkipJobMutation,
} from "@client/hooks/queries/useJobMutations"; } from "@client/hooks/queries/useJobMutations";
import { useCoverLetterGeneration } from "@client/hooks/useCoverLetterGeneration";
import { useProfile } from "@client/hooks/useProfile"; import { useProfile } from "@client/hooks/useProfile";
import { useSettings } from "@client/hooks/useSettings"; import { useSettings } from "@client/hooks/useSettings";
import type { Job, JobListItem } from "@shared/types.js"; import type { Job, JobListItem } from "@shared/types.js";
@ -81,6 +82,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
const previousSelectedJobIdRef = useRef<string | null>(null); const previousSelectedJobIdRef = useRef<string | null>(null);
const markAsAppliedMutation = useMarkAsAppliedMutation(); const markAsAppliedMutation = useMarkAsAppliedMutation();
const skipJobMutation = useSkipJobMutation(); const skipJobMutation = useSkipJobMutation();
const { generateForJob, inFlight: coverLetterGenerating } =
useCoverLetterGeneration(onJobUpdated);
const { personName } = useProfile(); const { personName } = useProfile();
const { renderMarkdownInJobDescriptions } = useSettings(); const { renderMarkdownInJobDescriptions } = useSettings();
@ -540,6 +543,19 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<Edit2 className="mr-2 h-4 w-4" /> <Edit2 className="mr-2 h-4 w-4" />
Edit details Edit details
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onSelect={() => void generateForJob(selectedJob.id)}
disabled={
selectedJob.status === "processing" || coverLetterGenerating
}
>
<FileText className="mr-2 h-4 w-4" />
{coverLetterGenerating
? "Cover letter…"
: selectedJob.coverLetter
? "Regenerate cover letter"
: "Generate cover letter"}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleCopyInfo()}> <DropdownMenuItem onSelect={() => void handleCopyInfo()}>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
Copy info Copy info
@ -602,7 +618,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<TabsContent value="overview" className="space-y-3 pt-2"> <TabsContent value="overview" className="space-y-3 pt-2">
<FitAssessment job={selectedJob} /> <FitAssessment job={selectedJob} />
<CoverLetterDisplay job={selectedJob} /> <CoverLetterDisplay
job={selectedJob}
onRequestGenerate={() => generateForJob(selectedJob.id)}
generateInFlight={coverLetterGenerating}
/>
<TailoredSummary job={selectedJob} /> <TailoredSummary job={selectedJob} />
<div className="grid gap-2 text-xs sm:grid-cols-2"> <div className="grid gap-2 text-xs sm:grid-cols-2">