feat(ui): add automation settings and safe AI review presentation

This commit is contained in:
cesnimda
2026-11-03 19:28:02 +01:00
parent a88310481f
commit e2327c1fa2
121 changed files with 4445 additions and 47 deletions
@@ -2,6 +2,7 @@ import React, { useState } from "react";
import CloseIcon from "@mui/icons-material/Close";
import { Alert, Button, IconButton, Stack, Typography } from "@mui/material";
import { useOptionalI18n } from "../i18n/I18nProvider";
import { useAccountPlan } from "../accountPlan";
type Props = {
featureKey: string;
@@ -15,6 +16,9 @@ function storageKey(featureKey: string) {
export default function ProFeatureNotice({ featureKey, title, children }: Props) {
const i18n = useOptionalI18n();
const { plan, canUseAi } = useAccountPlan();
const aiUnavailable = plan === "pro" && !canUseAi;
const displayTitle = aiUnavailable ? i18n?.t("aiUnavailableTitle") ?? "AI assistance is unavailable" : title;
const [dismissed, setDismissed] = useState(() => {
if (typeof window === "undefined") return false;
try {
@@ -40,15 +44,15 @@ export default function ProFeatureNotice({ featureKey, title, children }: Props)
severity="info"
action={(
<Stack direction="row" spacing={0.5} alignItems="center">
<Button href="/settings" size="small">{i18n?.t("proFeatureView") ?? "View Pro"}</Button>
<IconButton size="small" aria-label={i18n?.t("proFeatureDismiss", { title }) ?? `Dismiss ${title}`} onClick={dismiss}>
<Button href="/settings" size="small">{aiUnavailable ? i18n?.t("aiUnavailableSettings") ?? "Review AI settings" : i18n?.t("proFeatureView") ?? "View Pro"}</Button>
<IconButton size="small" aria-label={i18n?.t("proFeatureDismiss", { title: displayTitle }) ?? `Dismiss ${displayTitle}`} onClick={dismiss}>
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
)}
>
<Typography component="span" sx={{ fontWeight: 800 }}>{title}</Typography>{" "}
{children}
<Typography component="span" sx={{ fontWeight: 800 }}>{displayTitle}</Typography>{" "}
{aiUnavailable ? i18n?.t("aiUnavailableBody") ?? "You can continue editing manually. Review your AI settings and usage before trying again." : children}
</Alert>
);
}
+53 -30
View File
@@ -10,6 +10,7 @@ import {
InputLabel,
MenuItem,
Select,
Switch,
Tab,
Tabs,
Typography,
@@ -81,6 +82,7 @@ export default function SettingsView({
const rulesSession = useRef({ mounted: true, loading: false, saving: false });
const notificationSession = useRef({ mounted: true, loading: false, saving: false, generation: 0 });
const notificationBody = useRef<HTMLDivElement>(null);
const aiControls = useRef<HTMLDivElement>(null);
const notificationRetryFocus = useRef<HTMLElement | null>(null);
const currentTranslation = useRef(t);
const rulesDirty = Boolean(rulesDraft && savedRules && RULE_FIELDS.some(field => rulesDraft[field] !== String(savedRules[field])));
@@ -223,6 +225,36 @@ export default function SettingsView({
};
const notificationReadStatus = (notificationError?.cause as { response?: { status?: number } } | undefined)?.response?.status;
const notificationCard = (
<PageSection surface title={t("settingsNotificationsTitle")} description={t("settingsNotificationsBody")}>
<Box ref={notificationBody} role="group" aria-label={t("settingsNotificationsTitle")} tabIndex={-1} aria-busy={loadingNotifications || savingNotifications} sx={{ minWidth: 0 }}>
{!notificationPrefs ? <ViewStateNotice compact loading={loadingNotifications} title={t("settingsNotificationsLoadFailed")} error={notificationError ? {
kind: notificationReadStatus === 401 || notificationReadStatus === 403 ? "unauthorized" : "error",
message: getApiErrorMessage(notificationError.cause, t("settingsNotificationsLoadFailed")),
retryable: notificationReadStatus !== 401 && notificationReadStatus !== 403,
} : null} onRetry={retryNotifications} /> : <Box sx={{ display: "grid", gap: 1 }}>
{notificationError?.action ? <Alert severity="error" sx={{ mb: 1.5, overflowWrap: "anywhere" }}>{getApiErrorMessage(notificationError.cause, t("settingsNotificationsSaveFailed"))}</Alert> : null}
{notificationsDirty ? <Typography role="status" variant="body2" color="text.secondary">{t("assetsUnsavedChanges")}. {t("settingsNotificationsDraftScope")}</Typography> : null}
<FormControlLabel
control={<Switch checked={notificationPrefs.emailFollowUpRemindersEnabled} onChange={(e) => setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />}
label={t("settingsNotificationsFollowUpReminders")}
/>
<Typography variant="caption" color="text.secondary">{t("settingsNotificationsEmailBody")}</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
<Button disabled={!notificationsDirty || savingNotifications} onClick={discardNotifications}>{t("assetsDiscardChanges")}</Button>
<Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button>
</Box>
</Box>}
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
{t("settingsNotificationsDelivery")}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1.5 }}>
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
</Box>
</PageSection>
);
return (
<Box sx={{ minWidth: 0 }}>
<FormControl fullWidth sx={{ display: { xs: "flex", sm: "none" }, mb: 1 }}>
@@ -234,6 +266,7 @@ export default function SettingsView({
onChange={(event) => selectSection(Number(event.target.value))}
>
<MenuItem value={0}>{t("settingsTabGeneral")}</MenuItem>
<MenuItem value={4}>{t("settingsTabAutomation")}</MenuItem>
<MenuItem value={1}>{t("settingsTabFollowUps")}</MenuItem>
<MenuItem value={2}>{t("settingsTabNotifications")}</MenuItem>
<MenuItem value={3}>{t("settingsTabBackup")}</MenuItem>
@@ -241,10 +274,11 @@ export default function SettingsView({
</FormControl>
<Tabs aria-label={t("settings")} value={tab} onChange={(_, v) => selectSection(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}>
<Tab id="settings-tab-0" aria-controls="settings-panel-0" label={t("settingsTabGeneral")} />
<Tab id="settings-tab-1" aria-controls="settings-panel-1" label={t("settingsTabFollowUps")} />
<Tab id="settings-tab-2" aria-controls="settings-panel-2" label={t("settingsTabNotifications")} />
<Tab id="settings-tab-3" aria-controls="settings-panel-3" label={t("settingsTabBackup")} />
<Tab value={0} id="settings-tab-0" aria-controls="settings-panel-0" label={t("settingsTabGeneral")} />
<Tab value={4} id="settings-tab-4" aria-controls="settings-panel-4" label={t("settingsTabAutomation")} />
<Tab value={1} id="settings-tab-1" aria-controls="settings-panel-1" label={t("settingsTabFollowUps")} />
<Tab value={2} id="settings-tab-2" aria-controls="settings-panel-2" label={t("settingsTabNotifications")} />
<Tab value={3} id="settings-tab-3" aria-controls="settings-panel-3" label={t("settingsTabBackup")} />
</Tabs>
{rulesDirty ? <Typography role="status" variant="body2" sx={{ mt: 1, color: "text.secondary" }}>{t("assetsUnsavedChanges")}. {t("rulesDraftScope")}</Typography> : null}
@@ -331,7 +365,7 @@ export default function SettingsView({
</Box>
</PageSection>
<AiPrivacySettingsCard />
<Box ref={aiControls} id="settings-ai-controls" role="group" aria-label={t("settingsAiPrivacyTitle")} tabIndex={-1}><AiPrivacySettingsCard /></Box>
<AiUsageCard />
<QuickCaptureCard />
<PageSection surface title={t("connectedAccounts")} description={t("settingsConnectedAccountsBody")}
@@ -349,33 +383,22 @@ export default function SettingsView({
</TabPanel>
<TabPanel value={tab} index={2} label={t("settingsTabNotifications")}>
<PageSection surface title={t("settingsNotificationsTitle")} description={t("settingsNotificationsBody")}>
<Box ref={notificationBody} role="group" aria-label={t("settingsNotificationsTitle")} tabIndex={-1} aria-busy={loadingNotifications || savingNotifications} sx={{ minWidth: 0 }}>
{!notificationPrefs ? <ViewStateNotice compact loading={loadingNotifications} title={t("settingsNotificationsLoadFailed")} error={notificationError ? {
kind: notificationReadStatus === 401 || notificationReadStatus === 403 ? "unauthorized" : "error",
message: getApiErrorMessage(notificationError.cause, t("settingsNotificationsLoadFailed")),
retryable: notificationReadStatus !== 401 && notificationReadStatus !== 403,
} : null} onRetry={retryNotifications} /> : <Box sx={{ display: "grid", gap: 1 }}>
{notificationError?.action ? <Alert severity="error" sx={{ mb: 1.5, overflowWrap: "anywhere" }}>{getApiErrorMessage(notificationError.cause, t("settingsNotificationsSaveFailed"))}</Alert> : null}
{notificationsDirty ? <Typography role="status" variant="body2" color="text.secondary">{t("assetsUnsavedChanges")}. {t("settingsNotificationsDraftScope")}</Typography> : null}
<FormControlLabel
control={<Checkbox checked={notificationPrefs.emailFollowUpRemindersEnabled} onChange={(e) => setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />}
label={t("settingsNotificationsFollowUpReminders")}
/>
<Typography variant="caption" color="text.secondary">{t("settingsNotificationsEmailBody")}</Typography>
{notificationCard}
</TabPanel>
<TabPanel value={tab} index={4} label={t("settingsTabAutomation")}>
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
<PageSection surface title={t("settingsTabAutomation")} description={t("settingsAutomationBody")}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("settingsAutomationEmailSchedule")}</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
<Button disabled={!notificationsDirty || savingNotifications} onClick={discardNotifications}>{t("assetsDiscardChanges")}</Button>
<Button variant="contained" disabled={savingNotifications} onClick={() => void saveNotifications()}>{savingNotifications ? t("settingsSaving") : t("settingsNotificationsSave")}</Button>
<Button onClick={() => { selectSection(0); requestAnimationFrame(() => aiControls.current?.focus()); }}>{t("settingsAutomationAi")}</Button>
<Button onClick={() => selectSection(1)}>{t("settingsTabFollowUps")}</Button>
<Button onClick={() => navigate("/discover")}>{t("settingsAutomationSearches")}</Button>
</Box>
</Box>}
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
{t("settingsNotificationsDelivery")}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1.5 }}>
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
</Box>
</PageSection>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>{t("settingsAutomationRulesScope")}</Typography>
</PageSection>
{notificationCard}
</Box>
</TabPanel>
<TabPanel value={tab} index={3} label={t("settingsTabBackup")}>
@@ -1107,6 +1107,16 @@ async function reviewDocumentSuggestion(result: string) {
expect(request).not.toHaveProperty('targetId');
}
test('changing whole-document output language discards the previous-language review without applying it', async () => {
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
expect(await screen.findByText('Review 1 proposed change(s)')).toBeInTheDocument();
fireEvent.mouseDown(screen.getByLabelText('Output language'));
fireEvent.click(screen.getByRole('option', { name: 'Norsk bokmål' }));
expect(screen.queryByText('Review 1 proposed change(s)')).not.toBeInTheDocument();
expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/variants/3/ai/apply')).toHaveLength(0);
expect(mockedApi.post.mock.calls.filter(([url]) => url === '/cv/ai/assist')).toHaveLength(1);
});
test.each(['Apply', 'Reject'])('[PRES-C11 PRES-C07] whole-document AI %s changes only an explicitly accepted CV override', async action => {
await reviewDocumentSuggestion(JSON.stringify([{ id: 'headline', items: ['Reviewed headline'] }]));
expect(await screen.findByText('Review 1 proposed change(s)')).toBeInTheDocument();
+18
View File
@@ -2,6 +2,15 @@ export type UiLanguage = "en" | "nb";
export const translations = {
en: {
aiUnavailableTitle: "AI assistance is unavailable",
aiUnavailableSettings: "Review AI settings",
aiUnavailableBody: "You can continue editing manually. Review your AI settings and usage before trying again.",
settingsTabAutomation: "Automation",
settingsAutomationBody: "Control email follow-up reminders here. Use the linked settings for AI assistance, waiting periods and individual saved-search alerts.",
settingsAutomationEmailSchedule: "When reminder delivery is available, due or upcoming applications are checked about every six hours. Emails go only to your confirmed account address, at most once per application per day.",
settingsAutomationAi: "AI privacy and usage",
settingsAutomationSearches: "Saved-search alerts",
settingsAutomationRulesScope: "Day thresholds influence reminders and optional automatic Ghosted status updates. They do not disable status processing. Saved-search alerts are controlled individually in Discover; AI privacy and usage remain in General.",
documentsFilters: "Document filters",
documentsJumpResults: "Jump to documents",
documentsReturnFilters: "Return to document filters",
@@ -3197,6 +3206,15 @@ export const translations = {
coverAiReject: "Reject",
},
nb: {
aiUnavailableTitle: "KI-hjelp er utilgjengelig",
aiUnavailableSettings: "Se KI-innstillinger",
aiUnavailableBody: "Du kan fortsette å redigere manuelt. Se gjennom KI-innstillingene og forbruket ditt før du prøver igjen.",
settingsTabAutomation: "Automatisering",
settingsAutomationBody: "Styr e-postpåminnelser om oppfølging her. Bruk de tilknyttede innstillingene for AI-hjelp, ventetider og varsler for hvert lagret søk.",
settingsAutomationEmailSchedule: "Når påminnelseslevering er tilgjengelig, kontrolleres forfalte eller kommende søknader omtrent hver sjette time. E-post sendes bare til den bekreftede kontoadressen din, høyst én gang per søknad per dag.",
settingsAutomationAi: "AI-personvern og forbruk",
settingsAutomationSearches: "Varsler for lagrede søk",
settingsAutomationRulesScope: "Daggrenser påvirker påminnelser og valgfrie automatiske statusoppdateringer til Ghostet. De deaktiverer ikke statusbehandlingen. Varsler for lagrede søk styres enkeltvis i Finn jobber; AI-personvern og forbruk ligger under Generelt.",
documentsFilters: "Dokumentfiltre",
documentsJumpResults: "Gå til dokumenter",
documentsReturnFilters: "Tilbake til dokumentfiltre",
@@ -3,9 +3,31 @@ import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import ProFeatureNotice from "./components/ProFeatureNotice";
import { AccountPlanProvider } from "./accountPlan";
import { I18nProvider } from "./i18n/I18nProvider";
beforeEach(() => window.sessionStorage.clear());
test.each([
["en", "AI assistance is unavailable", "Review AI settings"],
["nb", "KI-hjelp er utilgjengelig", "Se KI-innstillinger"],
])("keeps paid users on the manual path without an upgrade prompt (%s)", (language, title, action) => {
window.localStorage.setItem("uiLanguage", language);
render(
<I18nProvider>
<AccountPlanProvider value={{ plan: "pro", canUseAi: false, canUseProThemes: true }}>
<ProFeatureNotice featureKey="cv-writing-ai" title="Upgrade prompt">Upgrade body</ProFeatureNotice>
</AccountPlanProvider>
</I18nProvider>,
);
expect(screen.getByText(title)).toBeInTheDocument();
expect(screen.getByRole("link", { name: action })).toHaveAttribute("href", "/settings");
expect(screen.queryByText("Upgrade body")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: new RegExp(title) }));
expect(screen.queryByText(title)).not.toBeInTheDocument();
window.localStorage.removeItem("uiLanguage");
});
test("shows a respectful upgrade action and remembers dismissal for the session", () => {
const { unmount } = render(
<ProFeatureNotice featureKey="test-feature" title="Draft faster with Pro.">
+25 -6
View File
@@ -319,12 +319,31 @@ test('[PRES-S03] Rules draft updates do not refetch an unrelated notification pr
});
function preferenceCopy(key: TranslationKey) { return translations[document.documentElement.lang === 'nb-NO' ? 'nb' : 'en'][key]; }
test.each(['en', 'nb'] as const)('%s Automation reuses the saved notification preference and explicit save', async language => {
localStorage.setItem('uiLanguage', language); renderView();
fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabAutomation') }));
const panel = within(screen.getByRole('tabpanel', { name: preferenceCopy('settingsTabAutomation') }));
const toggle = await panel.findByRole('switch', { name: preferenceCopy('settingsNotificationsFollowUpReminders') });
expect(toggle).toBeChecked(); fireEvent.click(toggle); expect(mockedApi.put).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabNotifications') }));
expect(await notificationPanel().findByRole('switch', { name: preferenceCopy('settingsNotificationsFollowUpReminders') })).not.toBeChecked();
fireEvent.click(notificationPanel().getByRole('button', { name: preferenceCopy('settingsNotificationsSave') }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/notification-settings', { emailFollowUpRemindersEnabled: false }));
});
test('Automation AI entry moves focus into existing settings without an AI request', async () => {
renderView(); fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabAutomation') }));
fireEvent.click(await screen.findByRole('button', { name: preferenceCopy('settingsAutomationAi') }));
await waitFor(() => expect(document.getElementById('settings-ai-controls')).toHaveFocus());
expect(mockedApi.post).not.toHaveBeenCalled();
});
function notificationPanel() { return within(screen.getByRole('tabpanel', { name: preferenceCopy('settingsTabNotifications') })); }
function notificationReads() { return mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings'); }
function notificationWrites() { return mockedApi.put.mock.calls.filter(([url]) => url === '/notification-settings'); }
async function openNotifications() {
fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabNotifications') }));
return notificationPanel().findByRole('checkbox', { name: preferenceCopy('settingsNotificationsFollowUpReminders') });
return notificationPanel().findByRole('switch', { name: preferenceCopy('settingsNotificationsFollowUpReminders') });
}
async function preferenceLanguage(language: 'en' | 'nb') {
await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
@@ -345,7 +364,7 @@ test('[P7-09 preferences] initial notification failure offers a single GET Retry
act(() => { retry.focus(); fireEvent.click(retry); fireEvent.click(retry); });
expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled();
await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } }));
expect(await notificationPanel().findByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(await notificationPanel().findByRole('switch', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationPanel().queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
expect(notificationPanel().getByRole('group', { name: translations.en.settingsNotificationsTitle })).toHaveFocus();
expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled();
@@ -359,7 +378,7 @@ test('[P7-09 preferences] notification drafts survive internal sections and loca
const retained = await openNotifications();
expect(retained).not.toBeChecked(); expect(notificationReads()).toHaveLength(reads);
expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.nb.settingsNotificationsDraftScope);
await preferenceLanguage('en'); expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
await preferenceLanguage('en'); expect(notificationPanel().getByRole('switch', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationReads()).toHaveLength(reads); expect(mockedApi.put).not.toHaveBeenCalled();
});
@@ -375,7 +394,7 @@ test('[P7-09 preferences] one held notification save acknowledges its snapshot a
expect(await openNotifications()).toBeChecked();
expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.en.assetsUnsavedChanges);
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges }));
expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationPanel().getByRole('switch', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
expect(notificationReads()).toHaveLength(1); expect(notificationWrites()).toHaveLength(1);
});
@@ -387,7 +406,7 @@ test('[P7-09 preferences] failed notification save keeps its current-locale draf
expect(await notificationPanel().findByRole('alert')).toHaveTextContent(translations.en.settingsNotificationsSaveFailed);
const reads = notificationReads().length; await preferenceLanguage('nb');
expect(notificationPanel().getByRole('alert')).toHaveTextContent(translations.nb.settingsNotificationsSaveFailed);
expect(notificationPanel().getByRole('checkbox', { name: translations.nb.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationPanel().getByRole('switch', { name: translations.nb.settingsNotificationsFollowUpReminders })).not.toBeChecked();
expect(notificationPanel().queryByRole('button', { name: translations.nb.retry })).not.toBeInTheDocument();
fireEvent.click(notificationPanel().getByRole('button', { name: translations.nb.settingsNotificationsSave }));
await waitFor(() => expect(notificationPanel().queryByRole('alert')).not.toBeInTheDocument());
@@ -403,7 +422,7 @@ test('[P7-09 preferences] discarding a notification draft leaves the independent
renderView(); fireEvent.change(await openRules(), { target: { value: '5' } });
fireEvent.click(await openNotifications());
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges }));
expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).toBeChecked();
expect(notificationPanel().getByRole('switch', { name: translations.en.settingsNotificationsFollowUpReminders })).toBeChecked();
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' }));
expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5);
+1 -1
View File
@@ -892,7 +892,7 @@ function DocumentAiTab({ settings, outline, onGenerate, onAccept }: CvAiCallback
setProposal(null);
setBusy(null);
return () => { requestId.current += 1; };
}, [sourceKey, settings.language, canUseAi]);
}, [sourceKey, settings.language, targetLanguage, canUseAi]);
const discard = () => {
requestId.current += 1;