fix(cv): bind letter proposals to current sources and preserve document choices

This commit is contained in:
cesnimda
2026-10-26 03:44:37 +01:00
parent 8948c988e2
commit 8979244576
21 changed files with 816 additions and 32 deletions
@@ -666,6 +666,8 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
<CoverLetterAiAssistant
jobId={jobId}
documentLanguage={settings?.language}
onDocumentLanguageChange={settings ? next => setSettingsDraft({ ...settings, language: next }) : undefined}
toolbarHost={aiToolbarHost}
currentText={text}
disabled={busy || confirming || loading || !data}
@@ -737,7 +739,7 @@ const COVER_LETTER_ACTIONS = [
{ key: "tailor", labelKey: "coverActionTailor" },
] as const;
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null }) {
function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbarHost, documentLanguage, onDocumentLanguageChange }: { jobId: number; currentText: string; disabled: boolean; onApply: (text: string, action: string) => void; toolbarHost: HTMLDivElement | null; documentLanguage?: "en" | "nb-NO"; onDocumentLanguageChange?: (language: "en" | "nb-NO") => void }) {
const labelId = useId();
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
@@ -746,11 +748,14 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
const [mode, setMode] = useState("professional");
const [language, setLanguage] = useState<"en" | "nb-NO">(() => uiLanguage === "nb" ? "nb-NO" : "en");
const [instructions, setInstructions] = useState("");
const [suggestion, setSuggestion] = useState<{ text: string; action: string } | null>(null);
const [suggestion, setSuggestion] = useState<{ id: number; text: string; action: string } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<AssetError | null>(null);
const busyRef = useRef(false);
const contentRef = useRef<HTMLDivElement>(null);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
useEffect(() => { if (documentLanguage) setLanguage(documentLanguage); }, [documentLanguage]);
const requestSequence = useRef(0);
useEffect(() => {
@@ -779,11 +784,39 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
});
if (!isCurrent() || request !== requestSequence.current) return;
const text = result.result.text?.trim() ?? "";
setSuggestion(text ? { text, action: requestedAction } : null);
setSuggestion(text ? { id: result.id, text, action: requestedAction } : null);
} catch (err) {
if (isCurrent() && request === requestSequence.current) {
const sourceRequired = (err as { response?: { data?: unknown } })?.response?.data === "cover_letter_source_required";
setError(sourceRequired ? { kind: "error", message: "", messageKey: "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
setError(sourceRequired || sourceChanged ? { kind: "error", message: "", messageKey: sourceChanged ? "coverAiSourceChanged" : "coverAiSourceRequired", retryable: false } : assetError(err, "coverAiGenerateFailed"));
}
} finally {
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
}
};
const applySuggestion = async () => {
if (!suggestion || busyRef.current || disabledRef.current || loadingCv) return;
if (!Number.isSafeInteger(suggestion.id) || suggestion.id <= 0) {
setSuggestion(null);
setError({ kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false });
return;
}
const request = ++requestSequence.current;
busyRef.current = true;
setBusy(true);
setError(null);
try {
await aiWorkspaceApi.validateSource(jobId, suggestion.id);
if (!isCurrent() || request !== requestSequence.current || disabledRef.current) return;
onApply(suggestion.text, suggestion.action);
setSuggestion(null);
} catch (err) {
if (isCurrent() && request === requestSequence.current) {
setSuggestion(null);
const sourceChanged = (err as { response?: { data?: { code?: string } } })?.response?.data?.code === "cover_letter_source_changed";
setError(sourceChanged ? { kind: "error", message: "", messageKey: "coverAiSourceChanged", retryable: false } : assetError(err, "coverAiGenerateFailed"));
}
} finally {
if (request === requestSequence.current) { busyRef.current = false; setBusy(false); }
@@ -835,7 +868,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<Select disabled={busy || disabled || loadingCv} labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => { const next = event.target.value as "en" | "nb-NO"; setLanguage(next); onDocumentLanguageChange?.(next); }}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</Select>
@@ -875,7 +908,7 @@ function CoverLetterAiAssistant({ jobId, currentText, disabled, onApply, toolbar
<TextField label={t("coverAiSuggestion")} fullWidth multiline minRows={3} value={suggestion.text} disabled={busy || disabled}
onChange={event => setSuggestion({ ...suggestion, text: event.target.value })} />
<Stack direction="row" spacing={1} sx={{ mt: 2 }}>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => { if (!busyRef.current && !disabled && !loadingCv) { onApply(suggestion.text, suggestion.action); setSuggestion(null); } }}>{t("coverAiApply")}</Button>
<Button size="small" variant="contained" disabled={busy || disabled || loadingCv || !suggestion.text.trim()} onClick={() => void applySuggestion()}>{t("coverAiApply")}</Button>
<Button size="small" disabled={busy || disabled || loadingCv} onClick={() => setSuggestion(null)}>{t("coverAiReject")}</Button>
</Stack>
</Paper>