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:
parent
54adc66e73
commit
6b26f70ef7
@ -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<void>;
|
||||
generateInFlight?: boolean;
|
||||
}
|
||||
|
||||
export const CoverLetterDisplay: React.FC<CoverLetterDisplayProps> = ({
|
||||
job,
|
||||
className,
|
||||
onRequestGenerate,
|
||||
generateInFlight = false,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@ -23,7 +29,54 @@ export const CoverLetterDisplay: React.FC<CoverLetterDisplayProps> = ({
|
||||
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 (
|
||||
<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'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 isLong = lines.length > 8;
|
||||
@ -32,26 +85,43 @@ export const CoverLetterDisplay: React.FC<CoverLetterDisplayProps> = ({
|
||||
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="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">
|
||||
<FileText className="h-3 w-3" />
|
||||
Cover Letter
|
||||
Cover letter
|
||||
</div>
|
||||
<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
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
{onRequestGenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRequestGenerate()}
|
||||
disabled={!canRunGenerate || generateInFlight}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{generateInFlight ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<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>
|
||||
<p className="text-xs text-foreground/85 leading-relaxed whitespace-pre-wrap">
|
||||
{displayText}
|
||||
|
||||
@ -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<ReadyPanelProps> = ({
|
||||
const previousJobIdRef = useRef<string | null>(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<ReadyPanelProps> = ({
|
||||
<div className="flex-1 py-4 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<FitAssessment job={job} />
|
||||
<CoverLetterDisplay
|
||||
job={job}
|
||||
onRequestGenerate={() => generateForJob(job.id)}
|
||||
generateInFlight={coverLetterGenerating}
|
||||
/>
|
||||
<TailoredSummary job={job} />
|
||||
|
||||
{googleDorks.length > 0 ? (
|
||||
|
||||
37
orchestrator/src/client/hooks/useCoverLetterGeneration.ts
Normal file
37
orchestrator/src/client/hooks/useCoverLetterGeneration.ts
Normal 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 };
|
||||
}
|
||||
@ -88,6 +88,7 @@ export const FloatingJobActionsBar: React.FC<FloatingJobActionsBarProps> = ({
|
||||
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
|
||||
</Button>
|
||||
|
||||
@ -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<JobDetailPanelProps> = ({
|
||||
const previousSelectedJobIdRef = useRef<string | null>(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<JobDetailPanelProps> = ({
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
Edit details
|
||||
</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()}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy info
|
||||
@ -602,7 +618,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
||||
|
||||
<TabsContent value="overview" className="space-y-3 pt-2">
|
||||
<FitAssessment job={selectedJob} />
|
||||
<CoverLetterDisplay job={selectedJob} />
|
||||
<CoverLetterDisplay
|
||||
job={selectedJob}
|
||||
onRequestGenerate={() => generateForJob(selectedJob.id)}
|
||||
generateInFlight={coverLetterGenerating}
|
||||
/>
|
||||
<TailoredSummary job={selectedJob} />
|
||||
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user