feat(ai): AI Workspace panel on every job application
Phase 5 frontend. A new "AI Workspace" tab in the job details dialog hosts the five suggestion modules (Job Analysis, Career Match, Cover Letter with tone, Interview Prep, Application Review) with a generate flow, a dependency-free markdown renderer for results, and a history sidebar (reuse / compare / copy / delete). Everything is suggestion-only — copy to keep; nothing auto-applies. - aiWorkspace.ts (types + API), components/AiWorkspacePanel.tsx, components/Markdown.tsx (no HTML injection surface — renders React nodes) - mounted as the last tab in JobDetailsDialog (index-safe, no reindexing) - 3 tests (generate flow, cover-letter mode, markdown); tsc + build clean Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import AiWorkspacePanel from "./components/AiWorkspacePanel";
|
||||
import Markdown from "./components/Markdown";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.",
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPanel() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<AiWorkspacePanel jobId={7} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url.includes("/modules")) return Promise.resolve({ data: { modules: [], provider: "gemini" } } as any);
|
||||
return Promise.resolve({ data: [] } as any); // history
|
||||
});
|
||||
});
|
||||
|
||||
test("renders modules and generates a suggestion into history", async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: { id: 1, module: "job-analysis", mode: null, title: "Job analysis", provider: "gemini", result: { text: "**Company**\nAcme" }, createdAtUtc: new Date().toISOString() },
|
||||
} as any);
|
||||
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Generate/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "job-analysis" })));
|
||||
expect(await screen.findByText("Acme")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("cover letter sends the selected mode", async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({
|
||||
data: { id: 2, module: "cover-letter", mode: "professional", title: "Cover letter · Professional", provider: "p", result: { text: "Dear team" }, createdAtUtc: new Date().toISOString() },
|
||||
} as any);
|
||||
|
||||
renderPanel();
|
||||
fireEvent.click(await screen.findByText("Cover Letter"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Generate/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", mode: "professional" })));
|
||||
});
|
||||
|
||||
test("Markdown renders headings, bold, and bullet lists", () => {
|
||||
render(<Markdown text={"# Title\n**Strengths**\n- one\n- two\nplain *word*"} />);
|
||||
expect(screen.getByText("Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Strengths")).toBeInTheDocument();
|
||||
expect(screen.getByText("one")).toBeInTheDocument();
|
||||
expect(screen.getByText("two")).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { api } from "./api";
|
||||
|
||||
export type AiInteraction = {
|
||||
id: number;
|
||||
module: string;
|
||||
mode: string | null;
|
||||
title: string;
|
||||
provider: string;
|
||||
result: { text?: string };
|
||||
createdAtUtc: string;
|
||||
};
|
||||
|
||||
export const AI_MODULES: { key: string; label: string; blurb: string }[] = [
|
||||
{ key: "job-analysis", label: "Job Analysis", blurb: "Break down the advert: skills, requirements, salary, work model, interview topics." },
|
||||
{ key: "career-match", label: "Career Match", blurb: "Your profile vs the advert: strengths, gaps, match %, suggested improvements." },
|
||||
{ key: "cover-letter", label: "Cover Letter", blurb: "A tailored cover letter in the tone you choose." },
|
||||
{ key: "interview", label: "Interview Prep", blurb: "Likely questions, STAR answers, company research, a checklist." },
|
||||
{ key: "application-review", label: "Application Review", blurb: "Grammar, gaps, weak areas, ATS issues, overall strength." },
|
||||
];
|
||||
|
||||
export const COVER_LETTER_MODES = ["professional", "friendly", "short", "detailed", "modern", "traditional"];
|
||||
|
||||
export const aiWorkspaceApi = {
|
||||
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 }) =>
|
||||
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),
|
||||
remove: (jobId: number, id: number) => api.delete(`/jobapplications/${jobId}/ai/history/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
|
||||
Skeleton, Stack, TextField, Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import CompareArrowsIcon from "@mui/icons-material/CompareArrows";
|
||||
import ReplayIcon from "@mui/icons-material/Replay";
|
||||
|
||||
import { getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import Markdown from "./Markdown";
|
||||
import { AI_MODULES, AiInteraction, COVER_LETTER_MODES, aiWorkspaceApi } from "../aiWorkspace";
|
||||
|
||||
// Phase 5 — the central AI Workspace for one job application. Every result is a suggestion the user
|
||||
// reviews and copies; nothing is applied automatically.
|
||||
export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
|
||||
const { toast } = useToast();
|
||||
const [module, setModule] = useState("job-analysis");
|
||||
const [mode, setMode] = useState("professional");
|
||||
const [extra, setExtra] = useState("");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [current, setCurrent] = useState<AiInteraction | null>(null);
|
||||
const [history, setHistory] = useState<AiInteraction[]>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(true);
|
||||
const [compareWith, setCompareWith] = useState<AiInteraction | null>(null);
|
||||
|
||||
const activeModule = AI_MODULES.find((m) => m.key === module)!;
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
setHistory(await aiWorkspaceApi.history(jobId));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
aiWorkspaceApi.modules(jobId).then((r) => setProvider(r.provider)).catch(() => undefined);
|
||||
loadHistory();
|
||||
}, [jobId, loadHistory]);
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
setCompareWith(null);
|
||||
try {
|
||||
const res = await aiWorkspaceApi.generate(jobId, { module, mode: module === "cover-letter" ? mode : undefined, extraContext: extra || undefined });
|
||||
setCurrent(res);
|
||||
setHistory((h) => [res, ...h]);
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, "AI generation failed."), "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await aiWorkspaceApi.remove(jobId, id);
|
||||
setHistory((h) => h.filter((x) => x.id !== id));
|
||||
if (current?.id === id) setCurrent(null);
|
||||
if (compareWith?.id === id) setCompareWith(null);
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, "Delete failed."), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const copy = (text: string) => {
|
||||
navigator.clipboard?.writeText(text);
|
||||
toast("Copied to clipboard.", "success");
|
||||
};
|
||||
|
||||
const moduleHistory = useMemo(() => history, [history]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 300px" }, gap: 2 }}>
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="info" sx={{ py: 0.5 }}>
|
||||
AI suggestions never change your profile, CVs, or this application. Review, then copy what you want to keep.
|
||||
{provider && <> Provider: <strong>{provider}</strong>.</>}
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
{AI_MODULES.map((m) => (
|
||||
<Chip key={m.key} label={m.label} color={m.key === module ? "primary" : "default"}
|
||||
variant={m.key === module ? "filled" : "outlined"} onClick={() => setModule(m.key)} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{activeModule.label}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{activeModule.blurb}</Typography>
|
||||
|
||||
{module === "cover-letter" && (
|
||||
<FormControl size="small" sx={{ minWidth: 180, mb: 1.5 }}>
|
||||
<InputLabel>Tone</InputLabel>
|
||||
<Select label="Tone" value={mode} onChange={(e) => setMode(e.target.value)}>
|
||||
{COVER_LETTER_MODES.map((m) => <MenuItem key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<TextField label="Extra context (optional)" size="small" fullWidth multiline minRows={2} sx={{ mb: 1.5 }}
|
||||
placeholder="Anything specific to emphasise…" value={extra} onChange={(e) => setExtra(e.target.value)} />
|
||||
|
||||
<Button variant="contained" startIcon={<AutoFixHighIcon />} disabled={busy} onClick={generate}>
|
||||
{busy ? "Generating…" : "Generate"}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{busy && <Skeleton variant="rounded" height={220} />}
|
||||
|
||||
{current && !busy && (
|
||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{current.title}</Typography>
|
||||
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => copy(current.result.text ?? "")}>Copy</Button>
|
||||
</Stack>
|
||||
<Markdown text={current.result.text ?? ""} />
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{compareWith && (
|
||||
<Paper variant="outlined" sx={{ p: 2, borderStyle: "dashed" }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Comparing: {compareWith.title} · {relTime(compareWith.createdAtUtc)}</Typography>
|
||||
<Button size="small" onClick={() => setCompareWith(null)}>Close</Button>
|
||||
</Stack>
|
||||
<Markdown text={compareWith.result.text ?? ""} />
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 1.5, alignSelf: "start" }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<HistoryIcon fontSize="small" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>History</Typography>
|
||||
</Stack>
|
||||
{loadingHistory && <Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={52} />)}</Stack>}
|
||||
{!loadingHistory && moduleHistory.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary">No AI runs yet. Generate one to build history.</Typography>
|
||||
)}
|
||||
<Stack spacing={1}>
|
||||
{moduleHistory.map((h) => (
|
||||
<Paper key={h.id} variant="outlined" sx={{ p: 1, bgcolor: current?.id === h.id ? "action.selected" : undefined }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{h.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{relTime(h.createdAtUtc)} · {h.provider}</Typography>
|
||||
<Stack direction="row" spacing={0.25} sx={{ mt: 0.5 }}>
|
||||
<Tooltip title="Reuse (show)"><IconButton size="small" aria-label="Reuse" onClick={() => setCurrent(h)}><ReplayIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
|
||||
<Tooltip title="Compare"><IconButton size="small" aria-label="Compare" onClick={() => setCompareWith(h)}><CompareArrowsIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
|
||||
<Tooltip title="Copy"><IconButton size="small" aria-label="Copy" onClick={() => copy(h.result.text ?? "")}><ContentCopyIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<Tooltip title="Delete"><IconButton size="small" aria-label="Delete" onClick={() => remove(h.id)}><DeleteOutlineIcon sx={{ fontSize: 16 }} /></IconButton></Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const mins = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines }
|
||||
|
||||
import Correspondence from "./Correspondence";
|
||||
import Attachments from "./Attachments";
|
||||
import AiWorkspacePanel from "./AiWorkspacePanel";
|
||||
import JobFlowBar from "./JobFlowBar";
|
||||
import GradientButton from "./GradientButton";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -697,6 +698,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<Tab label={t("jobDetailsTabInterviewPrep")} />
|
||||
<Tab label={t("jobTableReadiness")} />
|
||||
{isAdmin ? <Tab label={t("jobDetailsTabHistory")} /> : null}
|
||||
<Tab label="AI Workspace" />
|
||||
</Tabs>
|
||||
|
||||
{attachmentPicker}
|
||||
@@ -1270,6 +1272,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
{history.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoHistory")}</Typography> : history.map((entry) => <PaperRow key={entry.id} type={entry.type} oldValue={entry.oldValue} newValue={entry.newValue} at={entry.at} note={entry.note} />)}
|
||||
</Box>
|
||||
)}
|
||||
{/* AI Workspace is the last tab: index depends on whether the admin History tab is present. */}
|
||||
{tab === (isAdmin ? 10 : 9) && jobId && <AiWorkspacePanel jobId={jobId} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { Box, Link, Typography } from "@mui/material";
|
||||
|
||||
// Tiny, dependency-free markdown renderer for AI suggestions. Handles the subset the prompts emit:
|
||||
// #/##/### headings, **bold**, *italic*, [text](url), and - / * bullet lists. Everything is rendered
|
||||
// as React elements (no dangerouslySetInnerHTML), so there is no HTML-injection surface.
|
||||
export default function Markdown({ text }: { text: string }) {
|
||||
const blocks: React.ReactNode[] = [];
|
||||
const lines = (text ?? "").replace(/\r\n/g, "\n").split("\n");
|
||||
let list: string[] = [];
|
||||
|
||||
const flushList = (key: string) => {
|
||||
if (list.length === 0) return;
|
||||
blocks.push(
|
||||
<Box key={key} component="ul" sx={{ pl: 3, my: 0.5 }}>
|
||||
{list.map((li, i) => <li key={i}><Typography component="span" variant="body2">{inline(li)}</Typography></li>)}
|
||||
</Box>,
|
||||
);
|
||||
list = [];
|
||||
};
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const line = raw.trimEnd();
|
||||
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
|
||||
if (bullet) { list.push(bullet[1]); return; }
|
||||
flushList(`ul-${i}`);
|
||||
if (!line.trim()) return;
|
||||
const heading = line.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
blocks.push(
|
||||
<Typography key={i} variant={level === 1 ? "subtitle1" : "subtitle2"} sx={{ fontWeight: 800, mt: 1, mb: 0.25 }}>
|
||||
{inline(heading[2])}
|
||||
</Typography>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A line that is only **bold** reads as a section heading in these prompts.
|
||||
const boldOnly = line.match(/^\*\*(.+)\*\*:?$/);
|
||||
if (boldOnly) {
|
||||
blocks.push(<Typography key={i} variant="subtitle2" sx={{ fontWeight: 800, mt: 1, mb: 0.25 }}>{boldOnly[1]}</Typography>);
|
||||
return;
|
||||
}
|
||||
blocks.push(<Typography key={i} variant="body2" sx={{ mb: 0.5 }}>{inline(line)}</Typography>);
|
||||
});
|
||||
flushList("ul-end");
|
||||
|
||||
return <Box>{blocks}</Box>;
|
||||
}
|
||||
|
||||
// Inline **bold**, *italic*, [text](url) → React nodes.
|
||||
function inline(text: string): React.ReactNode[] {
|
||||
const tokens: React.ReactNode[] = [];
|
||||
const rx = /\*\*(.+?)\*\*|\*(.+?)\*|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let k = 0;
|
||||
while ((m = rx.exec(text)) !== null) {
|
||||
if (m.index > last) tokens.push(text.slice(last, m.index));
|
||||
if (m[1] !== undefined) tokens.push(<strong key={k++}>{m[1]}</strong>);
|
||||
else if (m[2] !== undefined) tokens.push(<em key={k++}>{m[2]}</em>);
|
||||
else if (m[3] !== undefined) tokens.push(<Link key={k++} href={m[4]} target="_blank" rel="noopener noreferrer">{m[3]}</Link>);
|
||||
last = rx.lastIndex;
|
||||
}
|
||||
if (last < text.length) tokens.push(text.slice(last));
|
||||
return tokens;
|
||||
}
|
||||
Reference in New Issue
Block a user