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 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'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}
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
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"
|
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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user