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