devist 0.20.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
import { useI18n } from "@/i18n/I18nProvider";
import type { TKey } from "@/i18n/dict";
import {
  type Memory,
  type MemoryPriority,
  type MemoryScope,
  listMemories,
  softDeleteMemory,
  updateMemoryPriority,
} from "@/lib/queries";
import { formatDistanceToNow } from "date-fns";
import { ChevronDown, ChevronUp, Lock, Trash2 } 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 [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  async function refresh() {
    setLoading(true);
    setError(null);
    try {
      const all = await listMemories({
        scope: scope === "all" ? undefined : scope,
        search: search.trim() || undefined,
      });
      setMemories(all);
    } catch (e) {
      setError(String((e as Error)?.message ?? e));
    } finally {
      setLoading(false);
    }
  }

  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));
    } catch (e) {
      setError(String((e as Error)?.message ?? e));
    }
  }

  return (
    <div className="p-6 space-y-4 max-w-5xl">
      <header>
        <h1 className="text-xl font-semibold">{t("memory.title")}</h1>
      </header>

      {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)}
            onDelete={() => handleDelete(m)}
            promotable={PRIORITY_ORDER.indexOf(m.priority) > 0}
            demotable={PRIORITY_ORDER.indexOf(m.priority) < PRIORITY_ORDER.length - 1}
          />
        ))}
      </div>
    </div>
  );
}

function MemoryItem({
  memory,
  onPromote,
  onDemote,
  onDelete,
  promotable,
  demotable,
}: {
  memory: Memory;
  onPromote: () => void;
  onDemote: () => void;
  onDelete: () => void;
  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>
        <button
          type="button"
          onClick={onDelete}
          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>
  );
}