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,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