Implement S03 follow-up draft context loop
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
||||
} from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
|
||||
@@ -38,16 +38,8 @@ type AttachmentItem = {
|
||||
useForAi: boolean;
|
||||
};
|
||||
|
||||
type FollowUpDraft = {
|
||||
subject: string;
|
||||
body: string;
|
||||
reason: string;
|
||||
suggestedSendOn: string;
|
||||
};
|
||||
|
||||
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
|
||||
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
|
||||
type PackageDraftKind = "tailoredCv" | "coverLetter" | "applicationAnswer" | "recruiterMessage";
|
||||
|
||||
type PackageWorkspaceState = {
|
||||
coverLetter: string;
|
||||
@@ -612,9 +604,20 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<Box>
|
||||
{loadingDraft ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : followUpDraft ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">{t("jobDetailsReason")}</Typography><Typography>{followUpDraft.reason}</Typography></Box>
|
||||
<Box><Typography variant="overline">{t("jobDetailsSuggestedSendDate")}</Typography><Typography>{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()}</Typography></Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||
<Typography variant="overline">Follow-up context</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{followUpDraft.threadSubject ? <Chip size="small" variant="outlined" label={`Thread: ${followUpDraft.threadSubject}`} /> : null}
|
||||
{followUpDraft.lastCorrespondenceAt ? <Chip size="small" variant="outlined" label={`Last activity: ${new Date(followUpDraft.lastCorrespondenceAt).toLocaleDateString()}`} /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography sx={{ whiteSpace: "pre-wrap", mb: 1.5 }}>{followUpDraft.contextSummary}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
|
||||
<Box><Typography variant="overline">Why now</Typography><Typography>{followUpDraft.reason}</Typography></Box>
|
||||
<Box><Typography variant="overline">Last sender</Typography><Typography>{followUpDraft.lastCorrespondenceFrom ?? "No imported sender yet"}</Typography></Box>
|
||||
</Box>
|
||||
{followUpDraft.contextSignals?.length ? <Box sx={{ mt: 1.5 }}><ListCard title="Draft grounding" items={followUpDraft.contextSignals} /></Box> : null}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
@@ -629,9 +632,9 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</FormControl>
|
||||
<Button variant="outlined" onClick={() => setDraftReloadToken((value) => value + 1)}>{t("jobDetailsRegenerateDraft")}</Button>
|
||||
</Box>
|
||||
<TextField label={t("jobDetailsRecipient")} value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText={t("jobDetailsRecipientHelp")} />
|
||||
<TextField label={t("jobDetailsRecipient")} value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText={`${t("jobDetailsRecipientHelp")} Manual send only — nothing is dispatched until you press send.`} />
|
||||
<TextField label={t("jobDetailsSubject")} value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
|
||||
<TextField label={t("jobDetailsDraft")} multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} />
|
||||
<TextField label={t("jobDetailsDraft")} multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} helperText="You can edit this before sending. Sending stays manual and logs the sent note back to correspondence." />
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>{t("jobDetailsCopyDraft")}</Button>
|
||||
<Button variant="contained" disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()} onClick={async () => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { PromptProvider } from './prompt';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import JobDetailsDialog from './components/JobDetailsDialog';
|
||||
import { api } from './api';
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<JobDetailsDialog open jobId={42} onClose={() => {}} />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/jobapplications/42') {
|
||||
return Promise.resolve({ data: {
|
||||
id: 42,
|
||||
jobTitle: 'Backend Developer',
|
||||
status: 'Waiting',
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 10,
|
||||
company: { name: 'Acme', recruiterEmail: 'recruiter@acme.test' },
|
||||
tailoredCvText: 'Saved CV',
|
||||
coverLetterText: 'Saved cover letter',
|
||||
recruiterMessageDraft: 'Saved recruiter message',
|
||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved application answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
shortSummary: 'summary'
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/auth/me') {
|
||||
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/history') {
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
}
|
||||
if (url === '/attachments/42') {
|
||||
return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/followup-draft') {
|
||||
return Promise.resolve({ data: {
|
||||
subject: 'Re: Backend Developer application update',
|
||||
body: 'Hi Maria,\n\nI wanted to follow up on the Backend Developer thread and reiterate my fit for owning the API layer.\n\nThanks,\nCasey',
|
||||
reason: 'Scheduled follow-up is due.',
|
||||
suggestedSendOn: new Date().toISOString(),
|
||||
contextSummary: 'Scheduled follow-up is due. Latest thread activity was on March 10, 2026. Saved application package material is available for reuse.',
|
||||
contextSignals: ['Saved cover letter available', 'Saved tailored CV available', 'Thread participants: Maria Recruiter <maria@acme.test>, user@example.test'],
|
||||
threadSubject: 'Backend Developer application update',
|
||||
lastCorrespondenceFrom: 'Maria Recruiter <maria@acme.test>',
|
||||
lastCorrespondenceAt: new Date().toISOString(),
|
||||
} } as any);
|
||||
}
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
mockedApi.post.mockResolvedValue({ data: {} } as any);
|
||||
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('follow-up workspace shows thread grounding and keeps sending manual', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /follow up/i }));
|
||||
|
||||
expect(await screen.findByText(/follow-up context/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved application package material is available for reuse/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved cover letter available/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/manual send only/i)).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue(/i wanted to follow up on the backend developer thread/i)).toBeInTheDocument();
|
||||
|
||||
const draft = screen.getByDisplayValue(/i wanted to follow up on the backend developer thread/i);
|
||||
fireEvent.change(draft, { target: { value: 'Hi Maria,\n\nEdited follow-up.\n\nThanks,\nCasey' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /send and log email/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/send-followup', {
|
||||
toEmail: 'recruiter@acme.test',
|
||||
subject: 'Re: Backend Developer application update',
|
||||
body: 'Hi Maria,\n\nEdited follow-up.\n\nThanks,\nCasey',
|
||||
nextFollowUpAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,18 @@ export interface ReadinessResponse {
|
||||
reminders: string[];
|
||||
}
|
||||
|
||||
export interface FollowUpDraft {
|
||||
subject: string;
|
||||
body: string;
|
||||
reason: string;
|
||||
suggestedSendOn: string;
|
||||
contextSummary: string;
|
||||
contextSignals: string[];
|
||||
threadSubject?: string | null;
|
||||
lastCorrespondenceFrom?: string | null;
|
||||
lastCorrespondenceAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ApplicationPackageResponse {
|
||||
tailoredCvText: string;
|
||||
coverLetterDraft?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user