devist 0.10.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 {
  type WorkerJob,
  createGenerateRulesJob,
  getJob,
  getRule,
  listProjects,
  saveRule,
} from "@/lib/queries";
import { supabase } from "@/lib/supabase";
import type { ProjectSummary } from "@/types";
import { formatDistanceToNow } from "date-fns";
import { Save, Sparkles } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";

const PLACEHOLDER = `# Worker rules

Tell Claude what to focus on when generating advice for this scope.

## Tone
- Respond in Korean.
- Be concise.

## Focus
- Flag missing tests, security issues, dependency mismatches.
- Skip nitpicks (formatting, micro-style).

## Skip
- Don't comment on auto-generated files.
- Don't suggest framework migrations unless asked.
`;

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

export default function Rules() {
  const [projects, setProjects] = useState<ProjectSummary[]>([]);
  const [scope, setScope] = useState<string>("global");
  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);

  // 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(() => {
    listProjects()
      .then(setProjects)
      .catch((e) => setError(String(e?.message ?? e)));
  }, []);

  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;
    };
  }, [scope]);

  // 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 in case realtime misses (e.g. user reloaded after submit)
  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]);

  const scopes = useMemo(() => {
    const opts = [{ value: "global", label: "Global (~/.devist/worker/rules.md)" }];
    for (const p of projects) {
      opts.push({
        value: `project:${p.project}`,
        label: `Project · ${p.project}`,
      });
    }
    return opts;
  }, [projects]);

  async function onGenerate() {
    if (!intent.trim()) return;
    setError(null);
    try {
      const j = await createGenerateRulesJob(scope, intent.trim(), content);
      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">Rules</h1>
        <p className="text-sm text-muted-foreground">
          What you write here gets injected into the Claude prompt every time the worker generates
          advice. Saved here syncs to the local
          <code className="font-mono mx-1">rules.md</code> file via the worker daemon (~10s).
        </p>
      </header>

      <div className="flex items-center gap-3 flex-wrap">
        <label className="text-sm text-muted-foreground">Scope</label>
        <select
          value={scope}
          onChange={(e) => setScope(e.target.value)}
          className="rounded-md border bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
        >
          {scopes.map((s) => (
            <option key={s.value} value={s.value}>
              {s.label}
            </option>
          ))}
        </select>
        {updatedAt && (
          <span className="text-xs text-muted-foreground">
            updated {formatDistanceToNow(new Date(updatedAt), { addSuffix: true })}
          </span>
        )}
      </div>

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

      {/* 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">Describe what you want</h2>
          <span className="text-xs text-muted-foreground">
            — Claude generates a rules.md draft based on your description and the current content.
          </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" ? "Generating…" : "Queued…") : "Generate"}
          </Button>
          {job?.status === "error" && (
            <span className="text-xs text-red-600">Failed: {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">
              Draft generated. Review below, then "Use this draft" to load it into the editor (you
              can still edit before saving).
            </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}>
                Use this draft
              </Button>
              <Button size="sm" variant="ghost" onClick={() => setJob(null)}>
                Discard
              </Button>
            </div>
          </div>
        )}
      </section>

      {/* Editor */}
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder={PLACEHOLDER}
        disabled={loading || saving}
        spellCheck={false}
        className="w-full min-h-[420px] rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
      />

      <div className="flex items-center gap-3">
        <Button onClick={onSave} disabled={saving || loading} className="gap-2">
          <Save size={14} />
          {saving ? "Saving…" : "Save"}
        </Button>
        {savedAt && !saving && (
          <span className="text-xs text-green-600">
            Saved. Daemon will mirror to local file within ~10s.
          </span>
        )}
      </div>
    </div>
  );
}