devist 0.16.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
import { Button } from "@/components/ui/button";
import { useI18n } from "@/i18n/I18nProvider";
import { BUILTIN_RULES } from "@/lib/builtinRules";
import {
  type WorkerJob,
  createGenerateRulesJob,
  getJob,
  getRule,
  saveRule,
} from "@/lib/queries";
import { supabase } from "@/lib/supabase";
import { formatDistanceToNow } from "date-fns";
import { ChevronDown, ChevronRight, Lock, Save, Sparkles } from "lucide-react";
import { useEffect, useRef, useState } from "react";

const SCOPE = "global"; // single global scope after v0.12

const PLACEHOLDER = `# User rules

Add anything devist core doesn't already cover.

## Tone
- Respond in Korean.

## Focus
- (Add areas you want extra attention on.)

## Skip
- (Add patterns you want explicitly ignored.)
`;

const INTENT_PLACEHOLDER = `예: "한국어로 답변하고, Tailwind 클래스 순서는 무시. 보안 이슈만 warn으로 분류"`;

export default function Rules() {
  const { t, lang } = useI18n();
  const [content, setContent] = useState<string>("");
  const [updatedAt, setUpdatedAt] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [savedAt, setSavedAt] = useState<number | null>(null);
  const [showBuiltin, setShowBuiltin] = useState(false);

  // AI generation state
  const [intent, setIntent] = useState("");
  const [job, setJob] = useState<WorkerJob | null>(null);
  const jobChannelRef = useRef<ReturnType<NonNullable<typeof supabase>["channel"]> | null>(null);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    setError(null);
    getRule(SCOPE)
      .then((r) => {
        if (!alive) return;
        if (r) {
          setContent(r.content);
          setUpdatedAt(r.updated_at);
        } else {
          setContent("");
          setUpdatedAt(null);
        }
      })
      .catch((e) => alive && setError(String(e?.message ?? e)))
      .finally(() => alive && setLoading(false));
    return () => {
      alive = false;
    };
  }, []);

  // Subscribe to job updates whenever a job is in flight
  useEffect(() => {
    if (!job || !supabase) return;
    if (job.status === "done" || job.status === "error") return;
    const channel = supabase
      .channel(`worker_jobs:id=${job.id}`)
      .on(
        "postgres_changes",
        {
          event: "UPDATE",
          schema: "public",
          table: "worker_jobs",
          filter: `id=eq.${job.id}`,
        },
        (payload) => {
          setJob(payload.new as WorkerJob);
        },
      )
      .subscribe();
    jobChannelRef.current = channel;
    return () => {
      supabase?.removeChannel(channel);
      jobChannelRef.current = null;
    };
  }, [job]);

  // Fallback poll
  useEffect(() => {
    if (!job) return;
    if (job.status === "done" || job.status === "error") return;
    const t = setInterval(async () => {
      try {
        const fresh = await getJob(job.id);
        if (fresh) setJob(fresh);
      } catch {
        // ignore
      }
    }, 4000);
    return () => clearInterval(t);
  }, [job]);

  async function onGenerate() {
    if (!intent.trim()) return;
    setError(null);
    try {
      const j = await createGenerateRulesJob(SCOPE, intent.trim(), content, lang);
      setJob(j);
    } catch (e) {
      setError(String((e as Error)?.message ?? e));
    }
  }

  function useDraft() {
    if (job?.status !== "done") return;
    const draft = (job.output?.draft as string | undefined) ?? "";
    if (draft) {
      setContent(draft);
      setJob(null);
      setIntent("");
    }
  }

  async function onSave() {
    setSaving(true);
    setError(null);
    try {
      await saveRule(SCOPE, content);
      setSavedAt(Date.now());
      const r = await getRule(SCOPE);
      if (r) setUpdatedAt(r.updated_at);
    } catch (e) {
      setError(String((e as Error)?.message ?? e));
    } finally {
      setSaving(false);
    }
  }

  const generating = job?.status === "pending" || job?.status === "running";

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

      {error && (
        <div className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-700">
          {error}
        </div>
      )}

      {/* Built-in rules — read-only, collapsible */}
      <section className="rounded-md border bg-muted/20">
        <button
          type="button"
          onClick={() => setShowBuiltin((v) => !v)}
          className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 rounded-md"
        >
          <div className="flex items-center gap-2">
            <Lock size={14} className="text-muted-foreground" />
            <span className="text-sm font-semibold">{t("rules.builtin.title")}</span>
            <span className="text-xs text-muted-foreground">{t("rules.builtin.sub")}</span>
          </div>
          {showBuiltin ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
        </button>
        {showBuiltin && (
          <pre className="px-4 pb-4 text-xs whitespace-pre-wrap font-mono text-muted-foreground border-t pt-3">
            {BUILTIN_RULES}
          </pre>
        )}
      </section>

      {/* AI assistant */}
      <section className="rounded-md border bg-card p-4 space-y-3">
        <div className="flex items-center gap-2">
          <Sparkles size={16} className="text-cyan-600" />
          <h2 className="text-sm font-semibold">{t("rules.ai.title")}</h2>
          <span className="text-xs text-muted-foreground">{t("rules.ai.sub")}</span>
        </div>
        <textarea
          value={intent}
          onChange={(e) => setIntent(e.target.value)}
          placeholder={INTENT_PLACEHOLDER}
          disabled={generating}
          rows={3}
          className="w-full rounded-md border bg-background p-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
        />
        <div className="flex items-center gap-3">
          <Button
            onClick={onGenerate}
            disabled={generating || !intent.trim()}
            className="gap-2"
            variant="secondary"
          >
            <Sparkles size={14} />
            {generating
              ? job?.status === "running"
                ? t("rules.ai.btn.generating")
                : t("rules.ai.btn.queued")
              : t("rules.ai.btn.generate")}
          </Button>
          {job?.status === "error" && (
            <span className="text-xs text-red-600">
              {t("rules.ai.failed", { error: job.error ?? "unknown error" })}
            </span>
          )}
        </div>

        {job?.status === "done" && (
          <div className="space-y-2 mt-2 border-t pt-3">
            <div className="text-xs text-muted-foreground">{t("rules.ai.draftHint")}</div>
            <pre className="rounded border bg-muted/40 p-3 text-xs max-h-60 overflow-auto whitespace-pre-wrap">
              {(job.output?.draft as string) ?? ""}
            </pre>
            {((job.output?.explain as string | undefined) ?? "") && (
              <p className="text-xs text-muted-foreground italic">
                {(job.output?.explain as string | undefined) ?? ""}
              </p>
            )}
            <div className="flex items-center gap-2">
              <Button size="sm" onClick={useDraft}>
                {t("rules.ai.useDraft")}
              </Button>
              <Button size="sm" variant="ghost" onClick={() => setJob(null)}>
                {t("common.discard")}
              </Button>
            </div>
          </div>
        )}
      </section>

      {/* User rules editor */}
      <section className="space-y-2">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-semibold">{t("rules.user.title")}</h2>
          {updatedAt && (
            <span className="text-xs text-muted-foreground">
              {t("rules.user.updated", {
                ago: formatDistanceToNow(new Date(updatedAt), { addSuffix: true }),
              })}
            </span>
          )}
        </div>
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder={PLACEHOLDER}
          disabled={loading || saving}
          spellCheck={false}
          className="w-full min-h-[360px] rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
        />
        <div className="flex items-center gap-3">
          <Button onClick={onSave} disabled={saving || loading} className="gap-2">
            <Save size={14} />
            {saving ? t("rules.btn.saving") : t("common.save")}
          </Button>
          {savedAt && !saving && (
            <span className="text-xs text-green-600">{t("rules.saved")}</span>
          )}
        </div>
      </section>
    </div>
  );
}