feat: show diff view for AI CV rewrites
The master-CV rewrite preview replaced text without showing what changed -- the teardown flagged this as the biggest unmanaged AI risk (a rewrite silently upgrading "assisted with migration" to "led migration" was invisible). Add a "Show changes" toggle on the rewrite preview panel that renders a word-level diff (before = current master text or the targeted section's stored content, after = the AI's rewrite) instead of the flat replacement text. Defaults to off: an existing test proved diff-by-default breaks the familiar plain-text read (word-fragmented spans aren't matchable as one block), and it's a better UX default regardless -- read normally, opt into the diff when you want the trust signal. Uses the `diff` package (word-level diffWords) rather than hand-rolled LCS; no existing dependency covers this, and it's a solved problem.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { diffWords } from "diff";
|
||||
|
||||
// AI-mutation trust primitive (career-workspace-implementation-roadmap.md Phase F5): every AI
|
||||
// rewrite should show what it actually changed before the user accepts it, instead of silently
|
||||
// overwriting. Word-level diff keeps small edits readable; whole-paragraph rewrites still show
|
||||
// as one big change, which is itself useful signal ("this replaced almost everything").
|
||||
export default function TextDiff({ before, after }: { before: string; after: string }) {
|
||||
const parts = React.useMemo(() => diffWords(before ?? "", after ?? ""), [before, after]);
|
||||
|
||||
return (
|
||||
<Box sx={{ whiteSpace: "pre-wrap", fontSize: "0.875rem", lineHeight: 1.6 }}>
|
||||
{parts.map((part, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
component="span"
|
||||
sx={{
|
||||
backgroundColor: part.added ? "success.main" : part.removed ? "error.main" : "transparent",
|
||||
color: part.added || part.removed ? "common.white" : "text.primary",
|
||||
opacity: part.added || part.removed ? 0.85 : 1,
|
||||
textDecoration: part.removed ? "line-through" : "none",
|
||||
borderRadius: part.added || part.removed ? 0.5 : 0,
|
||||
px: part.added || part.removed ? 0.25 : 0,
|
||||
}}
|
||||
>
|
||||
{part.value}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -270,6 +270,14 @@ test('profile page rewrite tools use selected template and saved job context', a
|
||||
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument();
|
||||
|
||||
const showChangesToggle = screen.getByText(/show changes/i);
|
||||
fireEvent.click(showChangesToggle);
|
||||
expect(screen.queryByText(/clearer, sharper positioning for backend platform roles/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Clearer/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(showChangesToggle);
|
||||
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
||||
|
||||
const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i });
|
||||
fireEvent.click(buildCarouselButton);
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import TextDiff from "./components/TextDiff";
|
||||
|
||||
describe("TextDiff", () => {
|
||||
it("renders unchanged text without strike-through or highlight styling", () => {
|
||||
render(<TextDiff before="Backend engineer." after="Backend engineer." />);
|
||||
expect(screen.getByText("Backend engineer.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks removed words with a distinct style from unchanged text", () => {
|
||||
render(<TextDiff before="Assisted with the migration." after="Led the migration." />);
|
||||
const removed = screen.getByText("Assisted with", { selector: "span" });
|
||||
const unchanged = screen.getByText(/the migration\./, { selector: "span" });
|
||||
// jsdom doesn't resolve emotion's generated CSS cascade for getComputedStyle, so assert the
|
||||
// component branched into a different (MUI-generated) class for removed vs. unchanged text
|
||||
// rather than the literal computed decoration value.
|
||||
expect(removed.className).not.toBe(unchanged.className);
|
||||
});
|
||||
|
||||
it("treats an empty before as an entirely new addition", () => {
|
||||
render(<TextDiff before="" after="Brand new summary." />);
|
||||
expect(screen.getByText(/Brand new summary\./)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import AuthStatusCard from "../components/AuthStatusCard";
|
||||
import EmailProviderConnections from "../components/EmailProviderConnections";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import TextDiff from "../components/TextDiff";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import {
|
||||
@@ -256,6 +257,7 @@ export default function ProfilePage() {
|
||||
const [cvLanguage, setCvLanguage] = useState<CvBuilderLanguage>("English");
|
||||
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
||||
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
|
||||
const [showRewriteDiff, setShowRewriteDiff] = useState(false);
|
||||
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
||||
const [pdfCarousel, setPdfCarousel] = useState<PdfCarouselItem[]>([]);
|
||||
const [activePdfIndex, setActivePdfIndex] = useState(0);
|
||||
@@ -372,6 +374,11 @@ export default function ProfilePage() {
|
||||
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
|
||||
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
|
||||
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
|
||||
// What the rewrite is replacing, so the preview can show a diff instead of silently swapping
|
||||
// text out from under the user (career-workspace-implementation-roadmap.md Phase F5).
|
||||
const rewriteBeforeText = rewritePreview?.sectionName
|
||||
? structuredCv.sections.find((section) => section.name === rewritePreview.sectionName)?.content ?? ""
|
||||
: profileCvText;
|
||||
const activePdfItem = pdfCarousel[activePdfIndex] ?? null;
|
||||
|
||||
const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => {
|
||||
@@ -1180,13 +1187,28 @@ export default function ProfilePage() {
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
|
||||
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
|
||||
<Box sx={{ display: "flex", gap: 0.75, alignItems: "center" }}>
|
||||
{rewriteReady ? (
|
||||
<Chip
|
||||
size="small"
|
||||
variant={showRewriteDiff ? "filled" : "outlined"}
|
||||
color={showRewriteDiff ? "primary" : "default"}
|
||||
label="Show changes"
|
||||
onClick={() => setShowRewriteDiff((current) => !current)}
|
||||
/>
|
||||
) : null}
|
||||
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
|
||||
{rewriteReady ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
|
||||
showRewriteDiff ? (
|
||||
<TextDiff before={rewriteBeforeText} after={rewritePreview?.sectionName ? rewritePreview?.rewrittenText ?? "" : rewritePreview?.fullText ?? ""} />
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
|
||||
)
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user