Files
cv/app/client/screens/Workspace.tsx
T

199 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import type { ChatMessage, JobExtract, MatchResult, PlanResponse } from "../../shared/types";
import type { GeneratedResume } from "../App";
import { ApiRequestError, compilePdf, post } from "../api";
interface WorkspaceProps {
jd: JobExtract;
messages: ChatMessage[];
onMessagesChange: (messages: ChatMessage[]) => void;
onBack: () => void;
onGenerated: (result: GeneratedResume) => void;
}
export function Workspace({ jd, messages, onMessagesChange, onBack, onGenerated }: WorkspaceProps) {
const [match, setMatch] = useState<MatchResult | null>(null);
const [matchError, setMatchError] = useState("");
const [draft, setDraft] = useState("");
const [chatBusy, setChatBusy] = useState(false);
const [genStep, setGenStep] = useState("");
const [genError, setGenError] = useState("");
const chatEndRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
post<MatchResult>("/api/match", { text: jd.text })
.then((m) => {
if (!cancelled) setMatch(m);
})
.catch((err: unknown) => {
if (!cancelled) setMatchError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, [jd.text]);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, chatBusy]);
async function sendChat(): Promise<void> {
const content = draft.trim();
if (content.length === 0 || chatBusy || !match) return;
const next: ChatMessage[] = [...messages, { role: "user", content }];
onMessagesChange(next);
setDraft("");
setChatBusy(true);
try {
const { reply } = await post<{ reply: string }>("/api/chat", { jd, match, messages: next });
onMessagesChange([...next, { role: "assistant", content: reply }]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
onMessagesChange([...next, { role: "assistant", content: `(error) ${message}` }]);
} finally {
setChatBusy(false);
}
}
async function generate(): Promise<void> {
if (!match || genStep) return;
setGenError("");
try {
setGenStep("Planning selection with AI…");
const planRes = await post<PlanResponse>("/api/plan", { jd, match, chat: messages });
setGenStep("Rendering LaTeX…");
const { latex } = await post<{ latex: string }>("/api/latex", { plan: planRes.plan });
setGenStep("Compiling PDF…");
const blob = await compilePdf(latex);
onGenerated({
plan: planRes.plan,
source: planRes.source,
note: planRes.note,
latex,
pdfUrl: URL.createObjectURL(blob),
});
} catch (err) {
if (err instanceof ApiRequestError && err.log) {
setGenError(`${err.message}\n\n${err.log}`);
} else {
setGenError(err instanceof Error ? err.message : String(err));
}
setGenStep("");
}
}
const matchedItems = match?.items.filter((i) => i.score > 0) ?? [];
return (
<div className="workspace">
<header className="topbar">
<button type="button" className="btn ghost" onClick={onBack}>
New posting
</button>
<div className="topbar-title">
<strong>{jd.title}</strong>
<span className="muted">
{jd.source === "manual" ? "pasted manually" : `${jd.url} · extracted via ${jd.source}`}
</span>
</div>
<button type="button" className="btn primary" onClick={() => void generate()} disabled={!match || genStep !== ""}>
{genStep !== "" ? genStep : "Generate PDF"}
</button>
</header>
{genError && <pre className="error banner">{genError}</pre>}
<div className="workspace-grid">
<section className="panel">
<h2>Job Posting</h2>
<pre className="jd-text">{jd.text}</pre>
</section>
<section className="panel">
<h2>
Matched Material
{match && <span className="muted"> {match.skills.length} skills hit</span>}
</h2>
{matchError && <div className="error">{matchError}</div>}
{!match && !matchError && <div className="loading">Matching against the corpus</div>}
{match && (
<>
<div className="chips">
{match.skills.map((s) => (
<span key={s.name} className={`chip ${s.category}`} title={s.category}>
{s.name} <em>×{s.hits}</em>
</span>
))}
{match.skills.length === 0 && <span className="muted">No skill overlap found.</span>}
</div>
<div className="items">
{matchedItems.map((item) => (
<article key={`${item.type}:${item.slug}`} className="item-card">
<header>
<span className={`badge ${item.type}`}>{item.type}</span>
<strong>{item.title}</strong>
<span className="muted">
{item.company ? `${item.company} · ` : ""}
{item.dates} · score {item.score}
</span>
</header>
<div className="mini-chips">
{item.matchedSkills.map((s) => (
<span key={s} className="chip mini">
{s}
</span>
))}
</div>
<ul>
{item.bullets.slice(0, 3).map((b) => (
<li key={b.slice(0, 40)}>{b}</li>
))}
</ul>
</article>
))}
</div>
</>
)}
</section>
</div>
<section className="chat panel">
<h2>Refine with the agent</h2>
<div className="chat-log">
{messages.length === 0 && (
<div className="muted chat-empty">
Ask the agent to emphasize certain skills, pick different projects, or adjust the
headline the conversation is folded into the plan when you hit Generate.
</div>
)}
{messages.map((m, i) => (
<div key={`${i}-${m.content.slice(0, 20)}`} className={`msg ${m.role}`}>
{m.content}
</div>
))}
{chatBusy && <div className="msg assistant pending"></div>}
<div ref={chatEndRef} />
</div>
<form
className="chat-form"
onSubmit={(e) => {
e.preventDefault();
void sendChat();
}}
>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={match ? "e.g. Lead with the cryptography work, skip consumer apps" : "Waiting for match…"}
disabled={!match}
/>
<button type="submit" className="btn" disabled={!match || chatBusy || draft.trim().length === 0}>
Send
</button>
</form>
</section>
</div>
);
}