diff --git a/job-tracker-ui/src/ai-workspace-panel.test.tsx b/job-tracker-ui/src/ai-workspace-panel.test.tsx new file mode 100644 index 0000000..aaa5190 --- /dev/null +++ b/job-tracker-ui/src/ai-workspace-panel.test.tsx @@ -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; + +function renderPanel() { + return render( + + + , + ); +} + +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(); + expect(screen.getByText("Title")).toBeInTheDocument(); + expect(screen.getByText("Strengths")).toBeInTheDocument(); + expect(screen.getByText("one")).toBeInTheDocument(); + expect(screen.getByText("two")).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/aiWorkspace.ts b/job-tracker-ui/src/aiWorkspace.ts new file mode 100644 index 0000000..3d3ce57 --- /dev/null +++ b/job-tracker-ui/src/aiWorkspace.ts @@ -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(`/jobapplications/${jobId}/ai/generate`, body).then((r) => r.data), + history: (jobId: number, module?: string) => + api.get(`/jobapplications/${jobId}/ai/history`, { params: { module } }).then((r) => r.data), + remove: (jobId: number, id: number) => api.delete(`/jobapplications/${jobId}/ai/history/${id}`), +}; diff --git a/job-tracker-ui/src/components/AiWorkspacePanel.tsx b/job-tracker-ui/src/components/AiWorkspacePanel.tsx new file mode 100644 index 0000000..4c82d73 --- /dev/null +++ b/job-tracker-ui/src/components/AiWorkspacePanel.tsx @@ -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(null); + const [history, setHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(true); + const [compareWith, setCompareWith] = useState(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 ( + + + + AI suggestions never change your profile, CVs, or this application. Review, then copy what you want to keep. + {provider && <> Provider: {provider}.} + + + + {AI_MODULES.map((m) => ( + setModule(m.key)} /> + ))} + + + + {activeModule.label} + {activeModule.blurb} + + {module === "cover-letter" && ( + + Tone + + + )} + + setExtra(e.target.value)} /> + + + + + {busy && } + + {current && !busy && ( + + + {current.title} + + + + + )} + + {compareWith && ( + + + Comparing: {compareWith.title} · {relTime(compareWith.createdAtUtc)} + + + + + )} + + + + + + History + + {loadingHistory && {[0, 1, 2].map((i) => )}} + {!loadingHistory && moduleHistory.length === 0 && ( + No AI runs yet. Generate one to build history. + )} + + {moduleHistory.map((h) => ( + + {h.title} + {relTime(h.createdAtUtc)} · {h.provider} + + setCurrent(h)}> + setCompareWith(h)}> + copy(h.result.text ?? "")}> + + remove(h.id)}> + + + ))} + + + + ); +} + +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(); +} diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 5dcfdf2..b13dfb1 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -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, {isAdmin ? : null} + {attachmentPicker} @@ -1270,6 +1272,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {history.length === 0 ? {t("jobDetailsNoHistory")} : history.map((entry) => )} )} + {/* AI Workspace is the last tab: index depends on whether the admin History tab is present. */} + {tab === (isAdmin ? 10 : 9) && jobId && } ); diff --git a/job-tracker-ui/src/components/Markdown.tsx b/job-tracker-ui/src/components/Markdown.tsx new file mode 100644 index 0000000..ea2a03e --- /dev/null +++ b/job-tracker-ui/src/components/Markdown.tsx @@ -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( + + {list.map((li, i) =>
  • {inline(li)}
  • )} +
    , + ); + 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( + + {inline(heading[2])} + , + ); + return; + } + // A line that is only **bold** reads as a section heading in these prompts. + const boldOnly = line.match(/^\*\*(.+)\*\*:?$/); + if (boldOnly) { + blocks.push({boldOnly[1]}); + return; + } + blocks.push({inline(line)}); + }); + flushList("ul-end"); + + return {blocks}; +} + +// 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({m[1]}); + else if (m[2] !== undefined) tokens.push({m[2]}); + else if (m[3] !== undefined) tokens.push({m[3]}); + last = rx.lastIndex; + } + if (last < text.length) tokens.push(text.slice(last)); + return tokens; +}