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:
@@ -24,3 +24,4 @@ next-env.d.ts
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
Generated
+10
-17
@@ -27,6 +27,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axios": "^1.15.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"diff": "^9.0.0",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -7926,6 +7927,15 @@
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
|
||||
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-sequences": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
|
||||
@@ -17234,23 +17244,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"axios": "^1.15.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"diff": "^9.0.0",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
||||
@@ -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