jacquardSnapshot

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