import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useI18n } from "@/i18n/I18nProvider";
import type { TKey } from "@/i18n/dict";
import {
type Memory,
type MemoryPriority,
type MemoryScope,
approveProposedMemory,
createMemory,
listMemories,
listProposedMemories,
rejectProposedMemory,
softDeleteMemory,
updateMemoryPriority,
} from "@/lib/queries";
import { formatDistanceToNow } from "date-fns";
import {
Check,
ChevronDown,
ChevronUp,
Lightbulb,
Lock,
Plus,
Trash2,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
const SCOPE_TABS: ("all" | MemoryScope)[] = ["all", "project", "tech", "user"];
const PRIORITY_ORDER: MemoryPriority[] = ["constraint", "strong", "preference", "info"];
const PRIORITY_COLOR: Record<MemoryPriority, string> = {
constraint: "text-red-600 border-red-300 bg-red-50",
strong: "text-yellow-700 border-yellow-300 bg-yellow-50",
preference: "text-cyan-700 border-cyan-300 bg-cyan-50",
info: "text-muted-foreground border-muted bg-muted/30",
};
export default function Memory() {
const { t } = useI18n();
const [scope, setScope] = useState<"all" | MemoryScope>("all");
const [search, setSearch] = useState("");
const [memories, setMemories] = useState<Memory[]>([]);
const [proposed, setProposed] = useState<Memory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
async function refresh() {
setLoading(true);
setError(null);
try {
const [all, prop] = await Promise.all([
listMemories({
scope: scope === "all" ? undefined : scope,
search: search.trim() || undefined,
}),
listProposedMemories(),
]);
setMemories(all);
setProposed(prop);
} catch (e) {
setError(String((e as Error)?.message ?? e));
} finally {
setLoading(false);
}
}
async function handleApproveProposed(m: Memory) {
try {
await approveProposedMemory(m.id);
setProposed((prev) => prev.filter((p) => p.id !== m.id));
setMemories((prev) => [{ ...m, status: "active" }, ...prev]);
} catch (e) {
setError(String((e as Error)?.message ?? e));
}
}
async function handleRejectProposed(m: Memory) {
try {
await rejectProposedMemory(m.id);
setProposed((prev) => prev.filter((p) => p.id !== m.id));
} catch (e) {
setError(String((e as Error)?.message ?? e));
}
}
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scope, search]);
async function handlePromote(m: Memory) {
const idx = PRIORITY_ORDER.indexOf(m.priority);
if (idx <= 0) return;
const next = PRIORITY_ORDER[idx - 1];
try {
await updateMemoryPriority(m.id, next);
setMemories((prev) => prev.map((x) => (x.id === m.id ? { ...x, priority: next } : x)));
} catch (e) {
setError(String((e as Error)?.message ?? e));
}
}
async function handleDemote(m: Memory) {
const idx = PRIORITY_ORDER.indexOf(m.priority);
if (idx >= PRIORITY_ORDER.length - 1) return;
const next = PRIORITY_ORDER[idx + 1];
try {
await updateMemoryPriority(m.id, next);
setMemories((prev) => prev.map((x) => (x.id === m.id ? { ...x, priority: next } : x)));
} catch (e) {
setError(String((e as Error)?.message ?? e));
}
}
async function handleDelete(m: Memory) {
try {
await softDeleteMemory(m.id);
setMemories((prev) => prev.filter((x) => x.id !== m.id));
setConfirmDelete(null);
} catch (e) {
setError(String((e as Error)?.message ?? e));
}
}
async function handleCreate(input: {
text: string;
scope: MemoryScope;
priority: MemoryPriority;
project?: string;
tech?: string[];
}) {
try {
const created = await createMemory(input);
setMemories((prev) => [created, ...prev]);
setShowAdd(false);
} catch (e) {
setError(String((e as Error)?.message ?? e));
}
}
return (
<div className="p-6 space-y-4 max-w-5xl">
<header className="flex items-center justify-between gap-3">
<h1 className="text-xl font-semibold">{t("memory.title")}</h1>
<button
type="button"
onClick={() => setShowAdd((v) => !v)}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border bg-foreground text-background hover:opacity-90"
>
<Plus size={13} />
{t("memory.action.add")}
</button>
</header>
{showAdd && (
<AddMemoryForm
onSubmit={handleCreate}
onCancel={() => setShowAdd(false)}
/>
)}
{proposed.length > 0 && (
<ProposedSection
memories={proposed}
onApprove={handleApproveProposed}
onReject={handleRejectProposed}
/>
)}
{error && (
<div className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
{/* Tabs */}
<div className="flex border-b">
{SCOPE_TABS.map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={`px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${
scope === s
? "border-foreground text-foreground font-medium"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{t(`memory.tab.${s}` as TKey)}
</button>
))}
</div>
{/* Search */}
<div>
<input
type="search"
placeholder={t("memory.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* List */}
<div className="rounded-md border bg-card divide-y">
{loading && (
<div className="p-4 text-sm text-muted-foreground">{t("common.loading")}</div>
)}
{!loading && memories.length === 0 && (
<div className="p-6 text-sm text-muted-foreground text-center">
{t("memory.empty")}
</div>
)}
{memories.map((m) => (
<MemoryItem
key={m.id}
memory={m}
onPromote={() => handlePromote(m)}
onDemote={() => handleDemote(m)}
onDeleteRequest={() => setConfirmDelete(m.id)}
onDeleteConfirm={() => handleDelete(m)}
onDeleteCancel={() => setConfirmDelete(null)}
confirming={confirmDelete === m.id}
promotable={PRIORITY_ORDER.indexOf(m.priority) > 0}
demotable={PRIORITY_ORDER.indexOf(m.priority) < PRIORITY_ORDER.length - 1}
/>
))}
</div>
</div>
);
}
function ProposedSection({
memories,
onApprove,
onReject,
}: {
memories: Memory[];
onApprove: (m: Memory) => void;
onReject: (m: Memory) => void;
}) {
const { t } = useI18n();
return (
<div className="rounded-md border border-amber-300/60 bg-amber-50/40 p-4 space-y-3">
<div className="flex items-center gap-2">
<Lightbulb size={14} className="text-amber-600" />
<h2 className="text-sm font-semibold text-amber-900">
{t("memory.proposed.heading", { n: memories.length })}
</h2>
</div>
<p className="text-xs text-amber-800/80">
{t("memory.proposed.subtitle")}
</p>
<ul className="space-y-2">
{memories.map((m) => (
<li
key={m.id}
className="rounded border bg-card p-3 space-y-2"
>
<div className="flex items-center gap-2 flex-wrap text-xs">
<span
className={`text-[10px] uppercase font-semibold border rounded px-1.5 py-0.5 ${PRIORITY_COLOR[m.priority]}`}
>
{t(`memory.priority.${m.priority}` as TKey)}
</span>
<span className="uppercase tracking-wide text-muted-foreground">
{m.scope}
</span>
{m.project && (
<span className="font-mono text-muted-foreground">
{m.project}
</span>
)}
{m.tech.length > 0 && (
<span className="text-cyan-600">{m.tech.join(", ")}</span>
)}
</div>
<div className="text-sm leading-relaxed">{m.text}</div>
{m.proposed_reason && (
<div className="text-xs text-muted-foreground italic">
{t("memory.proposed.becauseLabel")}: {m.proposed_reason}
</div>
)}
<div className="flex gap-1.5 pt-1">
<button
type="button"
onClick={() => onApprove(m)}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-green-400 bg-green-50 text-green-800 hover:bg-green-100"
>
<Check size={12} />
{t("memory.proposed.approve")}
</button>
<button
type="button"
onClick={() => onReject(m)}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border bg-background text-muted-foreground hover:text-foreground hover:bg-muted"
>
<X size={12} />
{t("memory.proposed.reject")}
</button>
</div>
</li>
))}
</ul>
</div>
);
}
function MemoryItem({
memory,
onPromote,
onDemote,
onDeleteRequest,
onDeleteConfirm,
onDeleteCancel,
confirming,
promotable,
demotable,
}: {
memory: Memory;
onPromote: () => void;
onDemote: () => void;
onDeleteRequest: () => void;
onDeleteConfirm: () => void;
onDeleteCancel: () => void;
confirming: boolean;
promotable: boolean;
demotable: boolean;
}) {
const { t } = useI18n();
return (
<div className="p-4 space-y-2 hover:bg-muted/30">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`text-[10px] uppercase font-semibold border rounded px-1.5 py-0.5 ${PRIORITY_COLOR[memory.priority]}`}
>
{t(`memory.priority.${memory.priority}` as TKey)}
{memory.priority === "constraint" && (
<Lock size={10} className="inline ml-1" />
)}
</span>
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{memory.scope}
</span>
{memory.project && (
<span className="text-xs font-mono text-muted-foreground">
{memory.project}
</span>
)}
{memory.tech.length > 0 && (
<span className="text-xs text-cyan-600">{memory.tech.join(", ")}</span>
)}
<span className="text-xs text-muted-foreground ml-auto">
{formatDistanceToNow(new Date(memory.updated_at), { addSuffix: true })}
</span>
</div>
<div className="text-sm whitespace-pre-wrap">{memory.text}</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={onPromote}
disabled={!promotable}
title={t("memory.action.promote")}
className="p-1 rounded hover:bg-muted disabled:opacity-30 text-muted-foreground hover:text-foreground"
>
<ChevronUp size={14} />
</button>
<button
type="button"
onClick={onDemote}
disabled={!demotable}
title={t("memory.action.demote")}
className="p-1 rounded hover:bg-muted disabled:opacity-30 text-muted-foreground hover:text-foreground"
>
<ChevronDown size={14} />
</button>
{confirming ? (
<span className="ml-2 inline-flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("memory.delete.confirm")}
</span>
<button
type="button"
onClick={onDeleteConfirm}
className="px-2 py-0.5 rounded border border-red-300 bg-red-50 text-red-700 hover:bg-red-100"
>
{t("memory.delete.yes")}
</button>
<button
type="button"
onClick={onDeleteCancel}
className="px-2 py-0.5 rounded border bg-background text-muted-foreground hover:text-foreground"
>
{t("memory.delete.no")}
</button>
</span>
) : (
<button
type="button"
onClick={onDeleteRequest}
title={t("memory.action.delete")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-red-600"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
);
}
function AddMemoryForm({
onSubmit,
onCancel,
}: {
onSubmit: (input: {
text: string;
scope: MemoryScope;
priority: MemoryPriority;
project?: string;
tech?: string[];
}) => void;
onCancel: () => void;
}) {
const { t } = useI18n();
const [text, setText] = useState("");
const [scope, setScope] = useState<MemoryScope>("project");
const [priority, setPriority] = useState<MemoryPriority>("strong");
const [project, setProject] = useState("");
const [tech, setTech] = useState("");
const valid = text.trim().length > 0 && (scope !== "project" || project.trim().length > 0);
function submit() {
if (!valid) return;
onSubmit({
text: text.trim(),
scope,
priority,
project: scope === "project" ? project.trim() : undefined,
tech: scope === "tech"
? tech.split(",").map((s) => s.trim()).filter(Boolean)
: undefined,
});
}
return (
<div className="rounded-md border bg-card p-4 space-y-3">
<div className="text-sm font-semibold">{t("memory.add.heading")}</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t("memory.add.text.placeholder")}
rows={3}
className="w-full resize-none rounded border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1">
<div className="text-[10px] uppercase tracking-widest text-muted-foreground">
{t("memory.add.scope.label")}
</div>
<Select
value={scope}
onValueChange={(v) => setScope(v as MemoryScope)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="project">project</SelectItem>
<SelectItem value="tech">tech</SelectItem>
<SelectItem value="user">user</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="text-[10px] uppercase tracking-widest text-muted-foreground">
{t("memory.add.priority.label")}
</div>
<Select
value={priority}
onValueChange={(v) => setPriority(v as MemoryPriority)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="constraint">constraint</SelectItem>
<SelectItem value="strong">strong</SelectItem>
<SelectItem value="preference">preference</SelectItem>
<SelectItem value="info">info</SelectItem>
</SelectContent>
</Select>
</div>
{scope === "project" && (
<label className="space-y-1 sm:col-span-2">
<div className="text-[10px] uppercase tracking-widest text-muted-foreground">
{t("memory.add.project.label")}
</div>
<input
type="text"
value={project}
onChange={(e) => setProject(e.target.value)}
placeholder={t("memory.add.project.placeholder")}
className="w-full rounded border bg-background px-2 py-1.5 text-sm font-mono"
/>
</label>
)}
{scope === "tech" && (
<label className="space-y-1 sm:col-span-2">
<div className="text-[10px] uppercase tracking-widest text-muted-foreground">
{t("memory.add.tech.label")}
</div>
<input
type="text"
value={tech}
onChange={(e) => setTech(e.target.value)}
placeholder={t("memory.add.tech.placeholder")}
className="w-full rounded border bg-background px-2 py-1.5 text-sm font-mono"
/>
</label>
)}
</div>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onCancel}
className="text-xs px-3 py-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
>
{t("memory.add.cancel")}
</button>
<button
type="button"
onClick={submit}
disabled={!valid}
className="text-xs px-3 py-1.5 rounded bg-foreground text-background hover:opacity-90 disabled:opacity-40"
>
{t("memory.add.save")}
</button>
</div>
</div>
);
}