feat(cover-letter): add contextual AI workspace
This commit is contained in:
@@ -28,7 +28,7 @@ export const COVER_LETTER_MODES = ["professional", "friendly", "short", "detaile
|
||||
export const aiWorkspaceApi = {
|
||||
usage: () => api.get<AiUsage>("/ai/usage").then((r) => r.data),
|
||||
modules: (jobId: number) => api.get<{ modules: string[]; provider: string }>(`/jobapplications/${jobId}/ai/modules`).then((r) => r.data),
|
||||
generate: (jobId: number, body: { module: string; mode?: string; extraContext?: string }) =>
|
||||
generate: (jobId: number, body: { module: string; mode?: string; extraContext?: string; targetLanguage?: "en" | "nb-NO"; currentText?: string; action?: string }) =>
|
||||
api.post<AiInteraction>(`/jobapplications/${jobId}/ai/generate`, body).then((r) => r.data),
|
||||
history: (jobId: number, module?: string) =>
|
||||
api.get<AiInteraction[]>(`/jobapplications/${jobId}/ai/history`, { params: { module } }).then((r) => r.data),
|
||||
|
||||
@@ -182,6 +182,48 @@ test("an empty cover letter offers the template and an empty history", async ()
|
||||
.toContain("Dear Hiring Manager");
|
||||
});
|
||||
|
||||
test("AI cover letter suggestions use the linked CV and require explicit apply", async () => {
|
||||
routeGet();
|
||||
mockedApi.post.mockResolvedValue({
|
||||
data: {
|
||||
id: 9,
|
||||
module: "cover-letter",
|
||||
mode: "professional",
|
||||
title: "Cover letter · Generate · Professional",
|
||||
provider: "local",
|
||||
result: { text: "A tailored, truthful suggestion." },
|
||||
createdAtUtc: "2026-07-19T11:00:00Z",
|
||||
},
|
||||
} as any);
|
||||
|
||||
render(<ApplicationCoverLetterSection jobId={7} />);
|
||||
expect(await screen.findByText(/application's full job advert and analysis/i)).toBeInTheDocument();
|
||||
const assistantSelects = screen.getAllByRole("combobox");
|
||||
fireEvent.mouseDown(assistantSelects[assistantSelects.length - 1]);
|
||||
fireEvent.click(await screen.findByRole("option", { name: /Norsk bokmål/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Generate" }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/ai/generate",
|
||||
expect.objectContaining({
|
||||
module: "cover-letter",
|
||||
targetLanguage: "nb-NO",
|
||||
currentText: "Dear team",
|
||||
action: "generate",
|
||||
}),
|
||||
));
|
||||
expect(screen.getByLabelText("Cover letter")).toHaveValue("Dear team");
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Apply to editor/i }));
|
||||
expect(screen.getByLabelText("Cover letter")).toHaveValue("A tailored, truthful suggestion.");
|
||||
|
||||
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "A tailored, truthful suggestion." } } as any);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
|
||||
"/jobapplications/7/cover-letter",
|
||||
{ text: "A tailored, truthful suggestion.", source: "ai", aiAction: "generate" },
|
||||
));
|
||||
});
|
||||
|
||||
test("a failed load surfaces an error", async () => {
|
||||
mockedApi.get.mockRejectedValue(new Error("boom"));
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField,
|
||||
Tooltip, Typography,
|
||||
Alert, Box, Button, Chip, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
|
||||
Skeleton, Stack, TextField, Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import RichTextField from "./RichTextField";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import RestoreIcon from "@mui/icons-material/Restore";
|
||||
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import {
|
||||
ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi,
|
||||
} from "../applicationWorkspace";
|
||||
import { cvBuilderApi } from "../cvBuilder";
|
||||
import { aiWorkspaceApi } from "../aiWorkspace";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
|
||||
// Phase 5.4 — Application Assets sections for the workspace.
|
||||
//
|
||||
@@ -267,6 +270,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
[jobId],
|
||||
);
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [draftAiAction, setDraftAiAction] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// The textarea is only seeded from the server until the user starts typing, so a reload never
|
||||
@@ -279,11 +283,12 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
return () => onDirtyChange?.(false);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
const save = async (value: string, source = "manual") => {
|
||||
const save = async (value: string, source = "manual", aiAction?: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source));
|
||||
setData(await applicationAssetsApi.saveCoverLetter(jobId, value, source, aiAction));
|
||||
setDraft(null);
|
||||
setDraftAiAction(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not save the cover letter."));
|
||||
@@ -297,6 +302,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
try {
|
||||
setData(await applicationAssetsApi.restoreCoverLetter(jobId, version));
|
||||
setDraft(null);
|
||||
setDraftAiAction(null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not restore that version."));
|
||||
@@ -320,14 +326,14 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
value={text}
|
||||
disabled={busy}
|
||||
onChange={setDraft}
|
||||
placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below."
|
||||
placeholder="Write it yourself, start from the template, or generate a tailored draft."
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text)}>
|
||||
<Button variant="contained" disabled={busy || !dirty} onClick={() => save(text, draftAiAction ? "ai" : "manual", draftAiAction ?? undefined)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
|
||||
<Button disabled={busy || !dirty} onClick={() => { setDraft(null); setDraftAiAction(null); }}>
|
||||
Discard changes
|
||||
</Button>
|
||||
<Button
|
||||
@@ -343,6 +349,12 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
</Stack>
|
||||
</Shell>
|
||||
|
||||
<CoverLetterAiAssistant
|
||||
jobId={jobId}
|
||||
currentText={text}
|
||||
onApply={(value, aiAction) => { setDraft(value); setDraftAiAction(aiAction); }}
|
||||
/>
|
||||
|
||||
<Shell title="Version history" loading={loading} error={null}>
|
||||
{(data?.versions.length ?? 0) === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
@@ -391,6 +403,131 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
);
|
||||
}
|
||||
|
||||
const COVER_LETTER_ACTIONS = [
|
||||
{ key: "generate", label: "Generate" },
|
||||
{ key: "regenerate", label: "Fresh alternative" },
|
||||
{ key: "improve", label: "Improve" },
|
||||
{ key: "shorten", label: "Shorten" },
|
||||
{ key: "expand", label: "Add detail" },
|
||||
{ key: "professional", label: "More professional" },
|
||||
{ key: "natural", label: "More natural" },
|
||||
{ key: "grammar", label: "Fix grammar" },
|
||||
{ key: "tailor", label: "Tailor more closely" },
|
||||
] as const;
|
||||
|
||||
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
|
||||
const { canUseAi } = useAccountPlan();
|
||||
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
|
||||
const [action, setAction] = useState("generate");
|
||||
const [mode, setMode] = useState("professional");
|
||||
const [language, setLanguage] = useState<"en" | "nb-NO">("en");
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [suggestion, setSuggestion] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await aiWorkspaceApi.generate(jobId, {
|
||||
module: "cover-letter",
|
||||
mode,
|
||||
action,
|
||||
targetLanguage: language,
|
||||
currentText: currentText.trim() || undefined,
|
||||
extraContext: instructions.trim() || undefined,
|
||||
});
|
||||
setSuggestion(result.result.text?.trim() ?? "");
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, "Could not generate a cover-letter suggestion."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasCv = !!cv?.attachedVariantId;
|
||||
return (
|
||||
<Shell
|
||||
title="AI writing assistant"
|
||||
subtitle="Uses this job and its linked CV. Suggestions never overwrite your document."
|
||||
loading={loadingCv}
|
||||
error={null}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
{!hasCv ? (
|
||||
<Alert severity="info">Select a CV before generating a tailored cover letter.</Alert>
|
||||
) : (
|
||||
<Alert severity="success" variant="outlined" sx={{ py: 0.5 }}>
|
||||
Using <strong>{cv?.attachedVariantName}</strong> and this application's full job advert and analysis.
|
||||
</Alert>
|
||||
)}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel>Action</InputLabel>
|
||||
<Select label="Action" value={action} onChange={(event) => setAction(event.target.value)}>
|
||||
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{item.label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Tone</InputLabel>
|
||||
<Select label="Tone" value={mode} onChange={(event) => setMode(event.target.value)}>
|
||||
{[
|
||||
"professional", "friendly", "short", "detailed", "modern", "traditional",
|
||||
].map((item) => <MenuItem key={item} value={item}>{item[0].toUpperCase() + item.slice(1)}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 190 }}>
|
||||
<InputLabel>Document language</InputLabel>
|
||||
<Select label="Document language" value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="nb-NO">Norsk bokmål</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<TextField
|
||||
label="Additional instructions"
|
||||
placeholder="For example: Focus on my .NET experience and keep it concise."
|
||||
value={instructions}
|
||||
onChange={(event) => setInstructions(event.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
fullWidth
|
||||
/>
|
||||
{error && <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => void generate()}>Retry</Button>}>{error}</Alert>}
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AutoFixHighIcon />}
|
||||
disabled={!canUseAi || !hasCv || busy}
|
||||
onClick={() => void generate()}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{busy ? "Generating…" : canUseAi ? COVER_LETTER_ACTIONS.find((item) => item.key === action)?.label : "Pro required"}
|
||||
</Button>
|
||||
|
||||
{suggestion && (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<Paper variant="outlined" sx={{ p: 2, minWidth: 0 }}>
|
||||
<Typography variant="overline" color="text.secondary">Current</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||||
{currentText || "No current draft"}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<Paper variant="outlined" sx={{ p: 2, minWidth: 0, borderColor: "primary.main" }}>
|
||||
<Typography variant="overline" color="primary">Suggestion</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{suggestion}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
|
||||
<Button size="small" variant="contained" onClick={() => { onApply(suggestion, action); setSuggestion(""); }}>Apply to editor</Button>
|
||||
<Button size="small" onClick={() => setSuggestion("")}>Reject</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Application answer and recruiter message ----------
|
||||
|
||||
type PackageDrafts = { applicationAnswer: string; recruiterMessage: string };
|
||||
|
||||
Reference in New Issue
Block a user