v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)

This commit is contained in:
Amruth Pillai
2026-07-04 14:57:25 +02:00
committed by GitHub
parent 09bc6ec521
commit 57e9c8c487
181 changed files with 46794 additions and 11348 deletions
@@ -114,7 +114,7 @@ export function PasskeysSection() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.3, ease: "easeOut" }}
className="will-change-[transform,opacity]"
@@ -25,7 +25,7 @@ export function PasswordSection() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
className="flex items-center justify-between gap-x-4 will-change-[transform,opacity]"
@@ -36,7 +36,7 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
return (
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
>
@@ -30,7 +30,7 @@ export function TwoFactorSection() {
return (
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
>
@@ -10,7 +10,7 @@ export function AuthenticationSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
@@ -21,14 +21,14 @@ export function AuthenticationSettingsPage() {
<PasskeysSection />
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.4} />}
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.04} />}
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.5} />}
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.08} />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.6} />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.12} />}
{"custom" in enabledProviders && (
<SocialProviderSection provider="custom" animationDelay={0.7} name={enabledProviders.custom} />
<SocialProviderSection provider="custom" animationDelay={0.16} name={enabledProviders.custom} />
)}
</m.div>
);
@@ -17,11 +17,12 @@ import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type SavedProvider = RouterOutput["aiProviders"]["list"][number];
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string };
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string; defaultModel: string };
type ProviderRowProps = {
provider: SavedProvider;
@@ -33,71 +34,86 @@ const providerOptions: AIProviderOption[] = [
label: t`OpenAI`,
keywords: ["openai", "gpt", "chatgpt"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openai,
defaultModel: "gpt-4.1",
},
{
value: "anthropic",
label: t`Anthropic Claude`,
keywords: ["anthropic", "claude", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.anthropic,
defaultModel: "claude-3-5-sonnet-latest",
},
{
value: "gemini",
label: t`Google Gemini`,
keywords: ["gemini", "google"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.gemini,
defaultModel: "gemini-2.0-flash",
},
{
value: "vercel-ai-gateway",
label: t`Vercel AI Gateway`,
keywords: ["vercel", "gateway", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["vercel-ai-gateway"],
defaultModel: "openai/gpt-4.1",
},
{
value: "openrouter",
label: t`OpenRouter`,
keywords: ["openrouter", "router"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openrouter,
defaultModel: "openai/gpt-4.1",
},
{
value: "ollama",
label: t`Ollama`,
keywords: ["ollama", "local"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.ollama,
defaultModel: "llama3.1",
},
{
value: "openai-compatible",
label: t`OpenAI-compatible`,
keywords: ["compatible", "custom", "gateway"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["openai-compatible"],
defaultModel: "",
},
];
// Prefill Base URL + Model from the provider's known defaults when the provider changes.
function providerDefaults(provider: AIProvider) {
const option = providerOptions.find((entry) => entry.value === provider);
return {
baseURL: option?.defaultBaseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider] ?? "",
model: option?.defaultModel ?? "",
};
}
const emptyForm = {
label: "",
provider: "openai" as AIProvider,
model: "",
baseURL: "",
apiKey: "",
...providerDefaults("openai"),
};
function statusBadge(provider: SavedProvider) {
if (provider.testStatus === "success") {
return (
<Badge className="bg-emerald-600 text-white">
<Trans>Tested</Trans>
<Trans>Connected</Trans>
</Badge>
);
}
if (provider.testStatus === "failure") {
return (
<Badge variant="destructive">
<Trans>Failed</Trans>
<Trans>Connection failed</Trans>
</Badge>
);
}
return (
<Badge variant="secondary">
<Trans>Untested</Trans>
<Trans>Not connected</Trans>
</Badge>
);
}
@@ -220,15 +236,67 @@ function ProviderRow({ provider }: ProviderRowProps) {
);
}
type SaveResult = { ok: boolean; message: string };
function CreateProviderForm() {
const queryClient = useQueryClient();
const [form, setForm] = useState(emptyForm);
const [result, setResult] = useState<SaveResult | null>(null);
const selectedOption = useMemo(
() => providerOptions.find((option) => option.value === form.provider),
[form.provider],
);
const canCreate = form.label.trim() && form.model.trim() && form.apiKey.trim();
const { mutate: createProvider, isPending } = useMutation(orpc.aiProviders.create.mutationOptions());
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
const { mutateAsync: createProvider, isPending: isCreating } = useMutation(orpc.aiProviders.create.mutationOptions());
const { mutateAsync: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions());
const { mutateAsync: enableProvider, isPending: isEnabling } = useMutation(orpc.aiProviders.update.mutationOptions());
const isSaving = isCreating || isTesting || isEnabling;
// Model/label are prefilled from provider defaults, so step 1 (Provider + API Key) is enough to save.
const model = form.model.trim();
const label = form.label.trim() || String(selectedOption?.label ?? form.provider);
const canSave = Boolean(form.apiKey.trim() && model);
const save = async () => {
setResult(null);
try {
const created = await createProvider({
label,
provider: form.provider,
model,
baseURL: form.baseURL.trim(),
apiKey: form.apiKey.trim(),
});
// Test on save: verify the connection immediately instead of leaving it to a manual step.
const tested = await testProvider({ id: created.id });
if (tested.testStatus === "success") {
await enableProvider({ id: created.id, enabled: true });
setForm(emptyForm);
setResult({ ok: true, message: t`Connection verified — provider is ready to use.` });
} else {
// ponytail: provider stays persisted on failure so it shows in the list; a re-save creates a new row.
setResult({
ok: false,
message: tested.testError ?? t`Could not verify the connection. Check the API key, model, and base URL.`,
});
}
} catch (error) {
setResult({
ok: false,
message: getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI providers require REDIS_URL and ENCRYPTION_SECRET to be configured.`,
BAD_REQUEST: t`Invalid AI provider configuration.`,
},
fallback: t`Failed to save AI provider.`,
}),
});
} finally {
void invalidate();
}
};
return (
<div className="rounded-md border bg-card p-4">
@@ -241,19 +309,7 @@ function CreateProviderForm() {
</h3>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ai-label">
<Trans>Label</Trans>
</Label>
<Input
id="ai-label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
placeholder={t`Work OpenAI`}
/>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="ai-provider">
<Trans>Provider</Trans>
@@ -265,43 +321,12 @@ function CreateProviderForm() {
options={providerOptions}
onValueChange={(provider) => {
if (!provider) return;
setForm((current) => ({ ...current, provider }));
setForm((current) => ({ ...current, provider, ...providerDefaults(provider) }));
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
value={form.model}
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
placeholder={t`gpt-4.1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL</Trans>
</Label>
<Input
id="ai-base-url"
type="url"
value={form.baseURL}
onChange={(event) => setForm((current) => ({ ...current, baseURL: event.target.value }))}
placeholder={selectedOption?.defaultBaseURL || t`https://gateway.example.com/v1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="ai-api-key">
<Trans>API Key</Trans>
</Label>
@@ -318,42 +343,81 @@ function CreateProviderForm() {
data-1p-ignore="true"
/>
</div>
<details className="rounded-md border bg-background/50 px-3 py-2 [&_summary]:cursor-pointer">
<summary className="font-medium text-muted-foreground text-sm">
<Trans>Advanced</Trans>
</summary>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ai-label">
<Trans>Label</Trans>
</Label>
<Input
id="ai-label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
placeholder={selectedOption?.label ? String(selectedOption.label) : t`Work OpenAI`}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
value={form.model}
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
placeholder={t`gpt-4.1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL</Trans>
</Label>
<Input
id="ai-base-url"
type="url"
value={form.baseURL}
onChange={(event) => setForm((current) => ({ ...current, baseURL: event.target.value }))}
placeholder={selectedOption?.defaultBaseURL || t`https://gateway.example.com/v1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
</div>
</details>
</div>
<div className="mt-4 flex justify-end">
<Button
disabled={!canCreate || isPending}
onClick={() =>
createProvider(
{
label: form.label.trim(),
provider: form.provider,
model: form.model.trim(),
baseURL: form.baseURL.trim(),
apiKey: form.apiKey.trim(),
},
{
onSuccess: () => {
setForm(emptyForm);
toast.success(t`AI provider saved. Test it before use.`);
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
},
onError: (error) =>
toast.error(
getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI providers require REDIS_URL and ENCRYPTION_SECRET to be configured.`,
BAD_REQUEST: t`Invalid AI provider configuration.`,
},
fallback: t`Failed to save AI provider.`,
}),
),
},
)
}
{result ? (
<div
className={cn(
"mt-4 flex items-start gap-2 rounded-md border p-3 text-sm",
result.ok
? "border-emerald-300 bg-emerald-50 text-emerald-950 dark:bg-emerald-950/20 dark:text-emerald-200"
: "border-rose-300 bg-rose-50 text-rose-950 dark:bg-rose-950/20 dark:text-rose-200",
)}
>
{isPending ? <Spinner /> : <KeyIcon />}
<Trans>Save Provider</Trans>
{result.ok ? (
<CheckCircleIcon className="mt-0.5 shrink-0 text-emerald-600" />
) : (
<WarningCircleIcon className="mt-0.5 shrink-0 text-rose-600" />
)}
<span>{result.message}</span>
</div>
) : null}
<div className="mt-4 flex justify-end">
<Button disabled={!canSave || isSaving} onClick={() => void save()}>
{isSaving ? <Spinner /> : <KeyIcon />}
{isTesting ? <Trans>Testing</Trans> : <Trans>Save & Test Provider</Trans>}
</Button>
</div>
</div>
@@ -362,7 +426,7 @@ function CreateProviderForm() {
export function AISettingsSection() {
const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions());
const hasUsableProvider = providers?.some((provider) => provider.enabled && provider.testStatus === "success");
const { hasUsableProvider } = useHasUsableAiProvider();
const isConfigError = isAiProviderConfigError(error);
return (
@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/libs/orpc/client";
/**
* Single source of truth for "is an AI provider ready to use" (enabled AND its connection test succeeded).
* Replaces the predicate that was duplicated across the import dialog, agent setup, and AI settings.
*/
export function useHasUsableAiProvider() {
const { data: providers, isLoading } = useQuery(orpc.aiProviders.list.queryOptions());
const usableProviders = (providers ?? []).filter((provider) => provider.enabled && provider.testStatus === "success");
return {
hasUsableProvider: usableProviders.length > 0,
usableProviders,
isLoading,
};
}
@@ -9,7 +9,7 @@ export function IntegrationsSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-4xl gap-8 will-change-[transform,opacity]"
@@ -67,7 +67,7 @@ export function ApiKeysSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
@@ -1,6 +1,6 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { TrashSimpleIcon } from "@phosphor-icons/react";
import { DownloadSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { m } from "motion/react";
@@ -8,6 +8,7 @@ import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { useConfirm } from "@/hooks/use-confirm";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
@@ -23,6 +24,27 @@ export function DangerZoneSettingsPage() {
const { mutate: deleteAccount } = useMutation(orpc.auth.deleteAccount.mutationOptions());
const { mutate: exportData, isPending: isExporting } = useMutation(
orpc.auth.exportData.mutationOptions({
onSuccess: (data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
downloadWithAnchor(blob, generateFilename("reactive-resume-export", "json"));
toast.success(t`Your data has been exported successfully.`);
},
onError: (error) => {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when data export fails",
message: "Failed to export your data. Please try again.",
}),
),
);
},
}),
);
const handleDeleteAccount = async () => {
const confirmed = await confirm(t`Are you sure you want to delete your account?`, {
description: t`This action cannot be undone. All your data will be permanently deleted.`,
@@ -63,11 +85,31 @@ export function DangerZoneSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="grid gap-3">
<p className="leading-relaxed">
<Trans>Download a copy of all your data, including your profile and every resume, as a JSON file.</Trans>
</p>
<m.div
className="justify-self-start will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button variant="outline" onClick={() => exportData(undefined)} disabled={isExporting}>
<DownloadSimpleIcon />
<Trans>Export my data</Trans>
</Button>
</m.div>
</div>
<hr className="border-border" />
<p className="leading-relaxed">
<Trans>To delete your account, you need to enter the confirmation text and click the button below.</Trans>
</p>
@@ -9,7 +9,7 @@ import { ThemeCombobox } from "@/features/theme/combobox";
export function PreferencesSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
@@ -3,7 +3,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckIcon, WarningIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useRouter } from "@tanstack/react-router";
import { useRouteContext, useRouter } from "@tanstack/react-router";
import { AnimatePresence, m } from "motion/react";
import { toast } from "sonner";
import z from "zod";
@@ -33,6 +33,8 @@ type Props = {
export function ProfileSettingsPage({ session }: Props) {
const router = useRouter();
const context = useRouteContext({ strict: false });
const smtpEnabled = context.flags?.smtpEnabled ?? false;
const form = useAppForm({
defaultValues: {
@@ -130,7 +132,7 @@ export function ProfileSettingsPage({ session }: Props) {
return (
<m.form
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
@@ -226,7 +228,7 @@ export function ProfileSettingsPage({ session }: Props) {
<CheckIcon />
<Trans>Verified</Trans>
</p>
) : (
) : smtpEnabled ? (
<p className="flex items-center gap-x-1.5 text-amber-600 text-xs">
<WarningIcon className="size-3.5" />
<Trans>Unverified</Trans>
@@ -239,6 +241,10 @@ export function ProfileSettingsPage({ session }: Props) {
<Trans>Resend verification email</Trans>
</Button>
</p>
) : (
<p className="text-muted-foreground text-xs">
<Trans>Email delivery isn't configured on this instance, so verification is disabled.</Trans>
</p>
)}
</FormItem>
)}