import { Button } from "@/components/ui/button";
import { getRule, listProjects, saveRule } from "@/lib/queries";
import type { ProjectSummary } from "@/types";
import { formatDistanceToNow } from "date-fns";
import { Save } from "lucide-react";
import { useEffect, useMemo, 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.
`;
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);
// Load project list once
useEffect(() => {
listProjects()
.then(setProjects)
.catch((e) => setError(String(e?.message ?? e)));
}, []);
// Load selected scope's content
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]);
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 onSave() {
setSaving(true);
setError(null);
try {
await saveRule(scope, content);
setSavedAt(Date.now());
// refresh updated_at
const r = await getRule(scope);
if (r) setUpdatedAt(r.updated_at);
} catch (e) {
setError(String((e as Error)?.message ?? e));
} finally {
setSaving(false);
}
}
return (
<div className="p-6 space-y-4 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.
</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>
)}
<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"
/>
<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>
);
}