← snapshot
7370 bytes
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { api, ApiError } from "@/lib/api";
import { QUESTIONS } from "@/lib/jackie";
import type { ActorView, RemarkDto } from "@/lib/types";
import { FamilyTag } from "./family-tag";
import { RemarkThread, RemarkZone } from "./remarks";
/**
* Remarks against one decision, and the one thing an open suggestion affords:
* becoming a decision itself.
*
* The draft is deliberately not one click. What gets written is a *machine
* draft* built from the reviewer's words, and jacquard's whole position is
* that a machine draft and a human's endorsement are different objects — so a
* person reads it, names its families and scope, and proposes it under their
* own name. The remark then closes, pointing at what it became.
*/
/** The five real rationale families — the same taxonomy Jackie interviews on. */
const FAMILIES = QUESTIONS.map((q) => q.family);
/** First clause of what was said, as a title. Editable before it lands. */
function titleFrom(body: string): string {
const first = body.split(/[.;\n]/)[0]?.trim() ?? body.trim();
const clipped = first.length > 90 ? `${first.slice(0, 87)}…` : first;
return clipped.charAt(0).toUpperCase() + clipped.slice(1);
}
export function DecisionRemarks({
repo,
decisionId,
humans,
initial,
defaultScope,
}: {
repo: string;
decisionId: string;
humans: ActorView[];
initial: RemarkDto[];
defaultScope: string[];
}) {
const router = useRouter();
const [remarks, setRemarks] = useState(initial);
const [drafting, setDrafting] = useState<RemarkDto | null>(null);
const replace = (next: RemarkDto) =>
setRemarks((prev) => prev.map((r) => (r.id === next.id ? next : r)));
const mine = remarks.filter(
(r) => r.anchor.kind === "decision" && r.anchor.id === decisionId,
);
return (
<section style={{ marginTop: 28 }}>
<h3 className="jac-h3">Remarks</h3>
<p className="jac-small">
Select any sentence above to say something about it. A suggestion can
become a decision; the gate can only enforce decisions.
</p>
<RemarkThread
repo={repo}
remarks={mine}
onDraft={setDrafting}
onSettled={replace}
/>
{drafting ? (
<DraftFromRemark
repo={repo}
remark={drafting}
humans={humans}
defaultScope={defaultScope}
onCancel={() => setDrafting(null)}
onDone={(settled) => {
replace(settled);
setDrafting(null);
router.refresh();
}}
/>
) : null}
</section>
);
}
function DraftFromRemark({
repo,
remark,
humans,
defaultScope,
onCancel,
onDone,
}: {
repo: string;
remark: RemarkDto;
humans: ActorView[];
defaultScope: string[];
onCancel: () => void;
onDone: (settled: RemarkDto) => void;
}) {
const [title, setTitle] = useState(titleFrom(remark.body));
const [rationale, setRationale] = useState(
remark.anchor.quote
? `${remark.body}\n\nRaised against: “${remark.anchor.quote}”`
: remark.body,
);
// A suggestion about how something should behave is a constraint until its
// author says otherwise; that is the family the gate most often needs.
const [families, setFamilies] = useState<string[]>(["constraints"]);
const [scope, setScope] = useState(defaultScope.join(", "));
const [by, setBy] = useState(humans[0]?.id ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggle = (f: string) =>
setFamilies((prev) =>
prev.includes(f) ? prev.filter((x) => x !== f) : [...prev, f],
);
const propose = async () => {
setBusy(true);
setError(null);
try {
const decision = await api.proposeDecision(repo, {
title: title.trim(),
rationale: rationale.trim(),
families,
actor: { kind: "human", id: by },
scope: scope
.split(",")
.map((s) => s.trim())
.filter(Boolean),
});
const settled = await api.settleRemark(repo, remark.id, {
outcome: "drafted",
decision: decision.id,
});
onDone(settled);
} catch (e) {
setError(e instanceof ApiError ? e.message : "could not draft that");
} finally {
setBusy(false);
}
};
const valid = title.trim() && rationale.trim() && by && !busy;
return (
<div className="jac-remark-composer" style={{ borderLeftColor: "var(--jac-agent)" }}>
<p className="jac-panel-label">Drafted from a remark</p>
<p className="jac-small">
Your words, arranged as a decision. It lands <strong>unsettled</strong>
{" "}— proposing is not attesting, even for the person who said it.
</p>
<label className="jac-label" htmlFor="draft-title">
Title
</label>
<input
id="draft-title"
className="jac-select"
style={{ width: "100%" }}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<label className="jac-label" htmlFor="draft-rationale">
Rationale
</label>
<textarea
id="draft-rationale"
className="jac-textarea"
rows={5}
value={rationale}
onChange={(e) => setRationale(e.target.value)}
/>
<label className="jac-label">Families</label>
<div className="jac-remark-kinds">
{FAMILIES.map((f) => (
<button
key={f}
type="button"
className="jac-chip"
data-on={families.includes(f)}
onClick={() => toggle(f)}
>
<FamilyTag family={f} />
</button>
))}
</div>
<label className="jac-label" htmlFor="draft-scope">
Scope — comma separated path prefixes
</label>
<input
id="draft-scope"
className="jac-select"
style={{ width: "100%" }}
value={scope}
placeholder="src/net"
onChange={(e) => setScope(e.target.value)}
/>
{scope.trim() === "" ? (
<p className="jac-small jac-warn">
An empty scope governs nothing — the gate will never block on it.
</p>
) : null}
<div className="jac-remark-actions">
<label className="jac-small" htmlFor="draft-by">
proposed by
</label>
<select
id="draft-by"
className="jac-select"
value={by}
onChange={(e) => setBy(e.target.value)}
>
{humans.map((h) => (
<option key={h.id} value={h.id}>
{h.display_name ?? h.id}
</option>
))}
</select>
<span className="jac-spacer" />
<button type="button" className="jac-btn" onClick={onCancel}>
Cancel
</button>
<button
type="button"
className="jac-btn jac-btn--primary"
disabled={!valid}
onClick={() => void propose()}
>
{busy ? "Proposing…" : "Propose it"}
</button>
</div>
{error ? <p className="jac-small jac-error">{error}</p> : null}
</div>
);
}
/** Re-exported so the server page can wrap its rationale without importing two modules. */
export { RemarkZone };