Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

207 lines
8.2 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, Loader2, Search, UserRound } from "lucide-react";
import { useTranslation } from "react-i18next";
import PickerShell from "@/components/common/PickerShell";
import PickerHeader from "@/components/common/PickerHeader";
import { listPersonas, type PersonaInfo } from "@/lib/personas-api";
interface PersonaPickerProps {
open: boolean;
initialPersona: string | null;
onClose: () => void;
onApply: (selection: string | null) => void;
}
export default function PersonaPicker({
open,
initialPersona,
onClose,
onApply,
}: PersonaPickerProps) {
const { t } = useTranslation();
const [personas, setPersonas] = useState<PersonaInfo[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<string | null>(initialPersona);
const [query, setQuery] = useState("");
// Sync local state with the parent's current selection every time the
// picker is reopened so the modal always starts from the latest choice.
useEffect(() => {
if (!open) return;
setSelected(initialPersona);
setQuery("");
}, [open, initialPersona]);
useEffect(() => {
if (!open) return;
let mounted = true;
void (async () => {
setLoading(true);
try {
const items = await listPersonas({ force: true });
if (mounted) setPersonas(items);
} catch {
if (mounted) setPersonas([]);
} finally {
if (mounted) setLoading(false);
}
})();
return () => {
mounted = false;
};
}, [open]);
const filteredPersonas = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return personas;
return personas.filter((persona) => {
const name = persona.name.toLowerCase();
const desc = (persona.description || "").toLowerCase();
return name.includes(keyword) || desc.includes(keyword);
});
}, [personas, query]);
const handleApply = () => {
onApply(selected);
onClose();
};
return (
<PickerShell
open={open}
onClose={onClose}
labelledBy="persona-picker-title"
className="p-4 backdrop-blur-md"
backdropClass="bg-[var(--background)]/65"
>
<div className="surface-card w-full max-w-3xl overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-[0_22px_70px_rgba(0,0,0,0.18)]">
<PickerHeader
icon={UserRound}
titleId="persona-picker-title"
title={t("Select Persona")}
subtitle={t(
"Choose a behavior persona to apply, or pick No persona to use the default.",
)}
onClose={onClose}
/>
<div className="bg-[var(--background)]/40 p-5">
<button
type="button"
onClick={() => setSelected(null)}
className={`mb-3 flex w-full items-start gap-3 rounded-2xl border px-4 py-3 text-left transition-colors ${
selected === null
? "border-[var(--primary)]/40 bg-[var(--primary)]/8"
: "border-[var(--border)] bg-[var(--card)] hover:bg-[var(--muted)]/40"
}`}
>
<div
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors ${
selected === null
? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]"
: "border-[var(--border)] text-transparent"
}`}
>
<Check size={12} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[14px] font-medium text-[var(--foreground)]">
{t("No persona")}
</div>
<p className="mt-0.5 text-[12px] leading-5 text-[var(--muted-foreground)]">
{t("Use the default assistant behavior for this turn.")}
</p>
</div>
</button>
<div className="mb-4">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted-foreground)]" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("Search personas by name or description")}
className="w-full rounded-xl border border-[var(--border)] bg-[var(--card)] py-2.5 pl-9 pr-3 text-[13px] text-[var(--foreground)] outline-none transition focus:border-[var(--primary)]/50 focus:ring-2 focus:ring-[var(--primary)]/15"
/>
</div>
</div>
<div className="max-h-[48vh] overflow-y-auto rounded-2xl border border-[var(--border)] bg-[var(--card)]">
{loading ? (
<div className="flex min-h-[220px] items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-[var(--muted-foreground)]" />
</div>
) : filteredPersonas.length ? (
<div className="divide-y divide-[var(--border)]">
{filteredPersonas.map((persona) => {
const active = selected === persona.name;
return (
<button
key={persona.name}
onClick={() => setSelected(persona.name)}
className={`flex w-full items-start gap-3 px-4 py-3 text-left transition-colors ${
active
? "bg-[var(--primary)]/8"
: "hover:bg-[var(--muted)]/40"
}`}
>
<div
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors ${
active
? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]"
: "border-[var(--border)] text-transparent"
}`}
>
<Check size={12} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[14px] font-medium text-[var(--foreground)]">
{persona.name}
</span>
{persona.source === "admin" ? (
<span className="rounded-md bg-[var(--muted)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{t("preset")}
</span>
) : null}
</div>
{persona.description ? (
<p className="mt-1 line-clamp-2 text-[12px] leading-5 text-[var(--muted-foreground)]">
{persona.description}
</p>
) : null}
</div>
</button>
);
})}
</div>
) : (
<div className="px-6 py-14 text-center text-[13px] text-[var(--muted-foreground)]">
{personas.length === 0
? t("No personas yet")
: t("No matching personas found.")}
</div>
)}
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="text-[12px] text-[var(--muted-foreground)]">
{selected
? t("Persona: {{name}}", { name: selected })
: t("No persona selected")}
</div>
<button
onClick={handleApply}
className="btn-primary rounded-xl bg-[var(--primary)] px-4 py-2.5 text-[13px] font-medium text-[var(--primary-foreground)] transition-opacity hover:opacity-90"
>
{selected ? t("Use Persona") : t("Continue without persona")}
</button>
</div>
</div>
</div>
</PickerShell>
);
}