feat(ai): AI Workspace panel on every job application
CI and Deploy / test (push) Failing after 1m54s
CI and Deploy / deploy (push) Has been skipped

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:
cesnimda
2026-07-18 15:23:23 +02:00
parent f299d7be7c
commit bb0c0feb4c
5 changed files with 347 additions and 0 deletions
@@ -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();
});
+30
View File
@@ -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;
}