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>
);
}