diff --git a/apps/server/package.json b/apps/server/package.json
index 5d09c3149..cceea199e 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -77,7 +77,7 @@
"@types/node": "^26.1.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
- "@typescript/native-preview": "7.0.0-dev.20260704.1",
+ "@typescript/native-preview": "7.0.0-dev.20260705.1",
"tsdown": "^0.22.3",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
diff --git a/apps/web/package.json b/apps/web/package.json
index 242345739..d49889450 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -91,12 +91,16 @@
"@lingui/vite-plugin": "^6.4.0",
"@reactive-resume/config": "workspace:*",
"@rolldown/plugin-babel": "^0.2.3",
+ "@tanstack/devtools-vite": "^0.8.1",
+ "@tanstack/react-devtools": "^0.10.8",
+ "@tanstack/react-query-devtools": "^5.101.2",
+ "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.19",
"@types/babel__core": "^7.20.5",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
- "@typescript/native-preview": "7.0.0-dev.20260704.1",
+ "@typescript/native-preview": "7.0.0-dev.20260705.1",
"@vitejs/plugin-react": "^6.0.3",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-react-compiler": "^1.0.0",
diff --git a/apps/web/src/features/applications/components/application-actions-menu.tsx b/apps/web/src/features/applications/components/application-actions-menu.tsx
new file mode 100644
index 000000000..5ae2bb735
--- /dev/null
+++ b/apps/web/src/features/applications/components/application-actions-menu.tsx
@@ -0,0 +1,129 @@
+import type { Application } from "../types";
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import {
+ ArchiveIcon,
+ ArrowRightIcon,
+ DotsThreeVerticalIcon,
+ PencilSimpleIcon,
+ TrashIcon,
+ TrayArrowUpIcon,
+} from "@phosphor-icons/react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { STAGES } from "@reactive-resume/schema/applications/data";
+import { Button } from "@reactive-resume/ui/components/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@reactive-resume/ui/components/dropdown-menu";
+import { cn } from "@reactive-resume/utils/style";
+import { orpc } from "@/libs/orpc/client";
+import { applicationsListQueryKey } from "../queries";
+
+type Props = {
+ application: Application;
+ onEdit: (application: Application) => void;
+ // Whether the trigger should only appear on hover of the parent (cards). Rows keep it visible.
+ showOnHover?: boolean;
+ className?: string;
+};
+
+// Shared kebab menu for board cards and table rows: edit, move stage, archive, delete.
+export function ApplicationActionsMenu({ application, onEdit, showOnHover, className }: Props) {
+ const queryClient = useQueryClient();
+
+ const invalidate = () => {
+ void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
+ void queryClient.invalidateQueries({ queryKey: orpc.applications.stats.queryKey() });
+ void queryClient.invalidateQueries({ queryKey: orpc.applications.tags.queryKey() });
+ };
+
+ const update = useMutation(
+ orpc.applications.update.mutationOptions({
+ onSuccess: invalidate,
+ onError: () => toast.error(t`Something went wrong. Please try again.`),
+ }),
+ );
+
+ const remove = useMutation(
+ orpc.applications.delete.mutationOptions({
+ onSuccess: () => {
+ invalidate();
+ toast.success(t`Application deleted.`);
+ },
+ onError: () => toast.error(t`Couldn't delete the application.`),
+ }),
+ );
+
+ // Stop pointer/click from reaching the card (which would start a drag or open the detail panel).
+ const stop = (event: React.SyntheticEvent) => event.stopPropagation();
+
+ return (
+
+
+
+
+
+ }
+ />
+
+ onEdit(application)}>
+
+ Edit
+
+
+
+
+
+ Move to
+
+
+ {STAGES.map((stage) => (
+ update.mutate({ id: application.id, status: stage.value })}
+ >
+
+ {stage.label}
+
+ ))}
+
+
+
+ update.mutate({ id: application.id, archived: !application.archived })}>
+ {application.archived ? : }
+ {application.archived ? Unarchive : Archive}
+
+
+
+
+ remove.mutate({ id: application.id })}>
+
+ Delete
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/applications/components/application-ai-copilot.tsx b/apps/web/src/features/applications/components/application-ai-copilot.tsx
new file mode 100644
index 000000000..8c145b569
--- /dev/null
+++ b/apps/web/src/features/applications/components/application-ai-copilot.tsx
@@ -0,0 +1,269 @@
+import type { Application } from "../types";
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import {
+ ArrowsClockwiseIcon,
+ CaretRightIcon,
+ CopyIcon,
+ EnvelopeSimpleIcon,
+ MagicWandIcon,
+ PaperPlaneTiltIcon,
+ SparkleIcon,
+ SpinnerGapIcon,
+} from "@phosphor-icons/react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { toast } from "sonner";
+import { cn } from "@reactive-resume/utils/style";
+import { orpc } from "@/libs/orpc/client";
+import { applicationsListQueryKey } from "../queries";
+
+// Score bands drive the ring color and the label — a job-fit gauge, not a generic percentage.
+function band(score: number) {
+ if (score >= 75) return { color: "#10b981", label: t`Strong fit` };
+ if (score >= 50) return { color: "#f59e0b", label: t`Worth a shot` };
+ return { color: "#f43f5e", label: t`A stretch` };
+}
+
+function aiGaps(app: Application): string[] {
+ const meta = app.aiMetadata as { matchScore?: { gaps?: unknown } } | null | undefined;
+ const gaps = meta?.matchScore?.gaps;
+ return Array.isArray(gaps) ? gaps.filter((gap): gap is string => typeof gap === "string") : [];
+}
+
+// The signature element: a circular resume-fit gauge that animates to the score.
+function FitRing({ score }: { score: number }) {
+ const size = 60;
+ const stroke = 5;
+ const r = (size - stroke) / 2;
+ const c = 2 * Math.PI * r;
+ const { color } = band(score);
+ return (
+
+ );
+}
+
+type ActionRowProps = {
+ icon: React.ReactNode;
+ title: React.ReactNode;
+ description: React.ReactNode;
+ disabled?: boolean;
+ pending?: boolean;
+ onClick: () => void;
+};
+
+function ActionRow({ icon, title, description, disabled, pending, onClick }: ActionRowProps) {
+ return (
+
+ );
+}
+
+type Props = { application: Application };
+
+export function ApplicationAiCopilot({ application }: Props) {
+ const queryClient = useQueryClient();
+ const [draft, setDraft] = useState<{ kind: string; text: string } | null>(null);
+
+ const invalidate = () => {
+ void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
+ void queryClient.invalidateQueries({
+ queryKey: orpc.applications.getById.queryKey({ input: { id: application.id } }),
+ });
+ };
+
+ const matchScore = useMutation(
+ orpc.applications.ai.matchScore.mutationOptions({
+ onSuccess: invalidate,
+ onError: (error) => toast.error(error.message || t`Match scoring failed.`),
+ }),
+ );
+ const tailorResume = useMutation(
+ orpc.applications.ai.tailorResume.mutationOptions({
+ onSuccess: (result) => {
+ invalidate();
+ toast.success(t`Created "${result.name}" and linked it to this application.`);
+ },
+ onError: (error) => toast.error(error.message || t`Tailoring failed.`),
+ }),
+ );
+ const draftMessage = useMutation(
+ orpc.applications.ai.draftMessage.mutationOptions({
+ onSuccess: (result, variables) => setDraft({ kind: variables.kind, text: result.text }),
+ onError: (error) => toast.error(error.message || t`Drafting failed.`),
+ }),
+ );
+
+ const pending = matchScore.isPending || tailorResume.isPending || draftMessage.isPending;
+ const canScore = !!application.resumeId && !!application.jobDescription;
+ const score = application.matchScore;
+ const gaps = aiGaps(application);
+
+ return (
+
+
+
+
+
+
+ Application Copilot
+
+
+
+ {/* Fit gauge — the signature. Adapts to whether prerequisites are met and whether a score exists. */}
+
+ {!canScore ? (
+
+ Link a resume and add a job description (Edit) to score your fit and tailor a copy.
+
+ ) : score == null ? (
+
+ ) : (
+
+
+
+ {score}
+
+
+
+
+ {band(score).label}
+
+
+
+ {gaps.length > 0 && (
+
+ Gaps: {gaps.slice(0, 3).join(" · ")}
+
+ )}
+
+
+ )}
+
+
+
+
}
+ title={
Tailor my resume}
+ description={t`Create a copy tuned to this job`}
+ disabled={!canScore}
+ pending={tailorResume.isPending}
+ onClick={() => tailorResume.mutate({ id: application.id })}
+ />
+
}
+ title={
Draft a cover letter}
+ description={t`From your resume and the posting`}
+ pending={draftMessage.isPending && draft?.kind !== "follow-up"}
+ onClick={() => draftMessage.mutate({ id: application.id, kind: "cover-letter" })}
+ />
+
}
+ title={
Draft a follow-up}
+ description={t`A friendly nudge for the recruiter`}
+ pending={draftMessage.isPending && draft?.kind === "follow-up"}
+ onClick={() => draftMessage.mutate({ id: application.id, kind: "follow-up" })}
+ />
+
+
+ {draft && (
+
+
+
+ {draft.kind === "cover-letter" ? Cover letter draft : Follow-up draft}
+
+
+
+
+
+
+
+ {draft.text}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/applications/components/application-card.tsx b/apps/web/src/features/applications/components/application-card.tsx
new file mode 100644
index 000000000..c1d15ee41
--- /dev/null
+++ b/apps/web/src/features/applications/components/application-card.tsx
@@ -0,0 +1,115 @@
+import type { Application } from "../types";
+import { Trans } from "@lingui/react/macro";
+import { FileTextIcon, MapPinIcon } from "@phosphor-icons/react";
+import { getInitials } from "@reactive-resume/utils/string";
+import { cn } from "@reactive-resume/utils/style";
+import { ApplicationActionsMenu } from "./application-actions-menu";
+
+// Deterministic accent color for a company's logo tile (design shows colored initials tiles).
+const TILE_COLORS = [
+ "bg-rose-500",
+ "bg-orange-500",
+ "bg-amber-500",
+ "bg-emerald-500",
+ "bg-teal-500",
+ "bg-sky-500",
+ "bg-indigo-500",
+ "bg-violet-500",
+ "bg-fuchsia-500",
+];
+
+function tileColor(seed: string) {
+ let hash = 0;
+ for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
+ return TILE_COLORS[Math.abs(hash) % TILE_COLORS.length];
+}
+
+type Props = {
+ application: Application;
+ onClick?: () => void;
+ onEdit?: (application: Application) => void;
+ className?: string;
+ dragging?: boolean;
+};
+
+export function ApplicationCard({ application, onClick, onEdit, className, dragging }: Props) {
+ const followUp = application.followUpAt && !application.archived;
+
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: the card nests an actions-menu