refactor(applications): autofill from a pasted posting instead of a URL

Fetching an arbitrary job URL server side meant owning SSRF defence, redirect
and size limits, and per-site scraping quirks. The autofill tool now takes only
pasted text, so the URL input, the fetch path and its MCP annotation are gone.

The sheet gates the call behind a tested AI provider and a minimum paste length
so a stray snippet does not spend an AI call.
This commit is contained in:
Amruth Pillai
2026-08-17 22:32:32 +02:00
parent 7a14b0dfbc
commit da2f1f8244
6 changed files with 114 additions and 551 deletions
@@ -6,8 +6,8 @@ import { Trans } from "@lingui/react/macro";
import { SparkleIcon, XIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { STAGES } from "@reactive-resume/schema/applications/data";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
@@ -20,6 +20,7 @@ import {
SheetTitle,
} from "@reactive-resume/ui/components/sheet";
import { Textarea } from "@reactive-resume/ui/components/textarea";
import { toast } from "@reactive-resume/ui/components/toast";
import { Combobox } from "@/components/ui/combobox";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
@@ -27,6 +28,10 @@ import { FileAttachmentField } from "./file-attachment-field";
// Preset source suggestions surfaced via a <datalist>; the field itself stays free-text.
const SOURCE_OPTIONS = ["LinkedIn", "Indeed", "Company Website", "Referral", "Recruiter", "Other"];
// Mirrors the server-side cap on `applications.ai.autofill`.
const MAX_JOB_DESCRIPTION_CHARS = 20_000;
// ponytail: a paste shorter than this is a snippet, not a posting — don't burn an AI call on it.
const MIN_AUTOFILL_CHARS = 200;
const todayInputValue = () => new Date().toISOString().slice(0, 10);
const emptyForm = () => ({
@@ -99,6 +104,10 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
// Same gate as the rest of the AI surfaces: at least one enabled provider that tested green.
const { data: providers } = useQuery(orpc.aiProviders.list.queryOptions());
const aiEnabled = providers?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
@@ -117,11 +126,11 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
orpc.applications.create.mutationOptions({
onSuccess: () => {
invalidate();
toast.success(t`Application added to your pipeline.`);
toast.add({ type: "success", description: t`Application added to your pipeline.` });
setForm(emptyForm());
onOpenChange(false);
},
onError: () => toast.error(t`Couldn't add the application. Please try again.`),
onError: () => toast.add({ type: "error", description: t`Couldn't add the application. Please try again.` }),
}),
);
@@ -129,10 +138,10 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
orpc.applications.update.mutationOptions({
onSuccess: () => {
invalidate();
toast.success(t`Application updated.`);
toast.add({ type: "success", description: t`Application updated.` });
onOpenChange(false);
},
onError: () => toast.error(t`Couldn't save your changes. Please try again.`),
onError: () => toast.add({ type: "error", description: t`Couldn't save your changes. Please try again.` }),
}),
);
@@ -145,14 +154,19 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
role: result.role || prev.role,
location: result.location || prev.location,
salary: result.salary || prev.salary,
jobDescription: result.jobDescription || prev.jobDescription,
}));
toast.success(t`Filled in what we could from the posting.`);
toast.add({ type: "success", description: t`Filled in what we could from the posting.` });
},
onError: (error) => toast.error(error.message || t`Auto-fill failed. Paste the description instead.`),
onError: (error) => toast.add({ type: "error", description: error.message || t`Auto-fill failed.` }),
}),
);
const runAutofill = (jobDescription: string) => {
const posting = jobDescription.trim();
if (posting.length < MIN_AUTOFILL_CHARS || autofill.isPending) return;
autofill.mutate({ jobDescription: posting.slice(0, MAX_JOB_DESCRIPTION_CHARS) });
};
const pending = create.isPending || update.isPending;
const submit = () => {
@@ -195,32 +209,57 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4 [&>*]:shrink-0">
{/* AI job-posting autofill: extracts the fields below from a posting URL. */}
{!isEditing && (
<div className="rounded-lg border border-border border-dashed p-3">
<Label className="text-muted-foreground text-xs">
<Trans>Paste a job posting URL</Trans>
</Label>
<div className="mt-1.5 flex gap-2">
<Input
value={form.sourceUrl}
placeholder="https://…"
onChange={(event) => set("sourceUrl", event.target.value)}
/>
<Button
type="button"
variant="outline"
disabled={!form.sourceUrl.trim() || autofill.isPending}
onClick={() => autofill.mutate({ sourceUrl: form.sourceUrl.trim() })}
>
<SparkleIcon />
{autofill.isPending ? <Trans>Reading</Trans> : <Trans>Auto-fill</Trans>}
</Button>
</div>
<p className="mt-1.5 text-[11px] text-muted-foreground">
<Trans>Let AI read the posting and fill the fields below.</Trans>
</p>
</div>
{/* Pasted job description: stored with the application and used for every AI action.
Collapsed by default so the form stays short; hidden entirely when AI is off. */}
{aiEnabled && (
<Accordion className="rounded-lg border border-border border-dashed px-3">
<AccordionItem value="job-description">
<AccordionTrigger>
<span className="flex items-center gap-1.5">
<SparkleIcon className="text-primary" />
<Trans>Job description</Trans>
</span>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs">
<Trans>
Copy the entire job description from the posting and paste it below. We'll fill in the fields for
you and keep the text with this application for match scoring and tailoring.
</Trans>
</p>
<Textarea
// Fixed height: the accordion panel measures its content once, so a textarea that
// grew with the pasted text would overflow the clipped panel.
className="field-sizing-fixed h-40"
value={form.jobDescription}
rows={8}
maxLength={MAX_JOB_DESCRIPTION_CHARS}
placeholder={t`Paste the full job description here…`}
onChange={(event) => set("jobDescription", event.target.value)}
onPaste={(event) => runAutofill(event.clipboardData.getData("text"))}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] text-muted-foreground">
{autofill.isPending ? (
<Trans>Reading the posting…</Trans>
) : (
<Trans>Pasting fills the fields automatically.</Trans>
)}
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={form.jobDescription.trim().length < MIN_AUTOFILL_CHARS || autofill.isPending}
onClick={() => runAutofill(form.jobDescription)}
>
<SparkleIcon />
<Trans>Fill fields</Trans>
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
<Field label={t`Company`} required>
@@ -273,6 +312,15 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
</Field>
</div>
<Field label={t`Job posting link`}>
<Input
type="url"
value={form.sourceUrl}
placeholder="https://…"
onChange={(event) => set("sourceUrl", event.target.value)}
/>
</Field>
{!isEditing && (
<Field label={t`Stage date`}>
<Input
@@ -327,15 +375,6 @@ export function ApplicationFormSheet({ open, onOpenChange, application }: Props)
</Field>
</div>
<Field label={t`Job description`}>
<Textarea
value={form.jobDescription}
rows={3}
placeholder={t`Paste the posting — powers AI match scoring and tailoring.`}
onChange={(event) => set("jobDescription", event.target.value)}
/>
</Field>
<Field label={t`Notes`}>
<Textarea
value={form.notes}