jacquardSnapshot

← snapshot

23100 bytes
"use client";

import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError, api } from "@/lib/api";
import {
  type DecisionDraft,
  type Floor,
  type Place,
  buildFloor,
  matchSpoken,
  questionsFor,
  synthesize,
} from "@/lib/jackie";
import { useSpeech } from "@/lib/speech";
import type { ActorView, DecisionDto } from "@/lib/types";
import { DiffModal } from "../diff-modal";
import { ProvenanceLoom } from "../provenance-loom";
import { ActorBadge, ProvenanceBadge } from "../provenance-badge";
import { VerdictPanel } from "../verdict-panel";
import { JackieOrb, type OrbState, type OrbSubject } from "./jackie-orb";

type Phase = "loading" | "here" | "asking" | "draft" | "done" | "error";

interface Answer {
  family: string;
  ask: string;
  answer: string;
}

/**
 * Jackie showing you around.
 *
 * She never moves you. She says what she noticed, offers what there is to
 * look at, and waits. You go where you like — including back out. When there
 * is something worth recording she offers once; if you don't take her up on
 * it she doesn't ask again about that place.
 */
export function JackieViewing({ repo }: { repo: string }) {
  const speech = useSpeech();
  // These three are referentially stable by contract (see `useSpeech`), which
  // is what lets the effects below have honest dependency lists instead of a
  // render loop.
  const { speak, listen, stop: stopSpeech, canListen } = speech;
  const [phase, setPhase] = useState<Phase>("loading");
  const [floor, setFloor] = useState<Floor | null>(null);
  const [here, setHere] = useState<Place | null>(null);
  const [visited, setVisited] = useState<Set<string>>(new Set());
  const [declined, setDeclined] = useState<Set<string>>(new Set());
  const [line, setLine] = useState("");
  const [error, setError] = useState<string | null>(null);

  const [queue, setQueue] = useState<{ family: string; ask: string }[]>([]);
  const [qAt, setQAt] = useState(0);
  const [answers, setAnswers] = useState<Answer[]>([]);
  const [draft, setDraft] = useState<DecisionDraft | null>(null);
  const [typed, setTyped] = useState("");
  const [typing, setTyping] = useState(false);
  const [micBlocked, setMicBlocked] = useState<string | null>(null);
  const [proposed, setProposed] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);

  /* ---- diffs, opened over the tour rather than navigated to -------------
   * A walkthrough that drops you on another page is not a walkthrough, so a
   * diff comes up as a modal and Jackie keeps her place underneath it.
   */
  const [diff, setDiff] = useState<{ paths: string[]; initial?: string } | null>(null);
  const [diffCtx, setDiffCtx] = useState<{
    from: string;
    into: string | null;
    decisions: DecisionDto[];
    humans: ActorView[];
  } | null>(null);

  const answersRef = useRef<Answer[]>([]);
  const subjectRef = useRef<string | undefined>(undefined);
  const alive = useRef(true);

  useEffect(() => {
    alive.current = true;
    return () => {
      alive.current = false;
      stopSpeech();
    };
  }, [stopSpeech]);

  const say = useCallback(
    async (text: string) => {
      setLine(text);
      await speak(text);
    },
    [speak],
  );

  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const f = await buildFloor(repo);
        if (cancelled) return;
        setFloor(f);
        setPhase("here");
        void say(f.opening);
      } catch (e) {
        if (cancelled) return;
        setError(e instanceof ApiError ? e.message : String(e));
        setPhase("error");
      }
    })();
    return () => {
      cancelled = true;
    };
    // Intentionally once per repo — re-reading the floor mid-viewing would
    // move the ground under someone who is looking at something.
  }, [repo, say]);

  useEffect(() => {
    if (!floor) return;
    const gate = floor.places.find((p) => p.kind === "gate");
    let alive = true;
    void (async () => {
      const [detail, decisions] = await Promise.all([
        api.repo(repo).catch(() => null),
        api.decisions(repo).then((d) => d.decisions).catch(() => [] as DecisionDto[]),
      ]);
      if (!alive || !detail) return;
      let from: string | null = null;
      let into: string | null = null;
      if (gate?.kind === "gate") {
        const [f, i] = await Promise.all([
          api.log(repo, gate.from, 1).then((l) => l.head).catch(() => null),
          api.log(repo, gate.into, 1).then((l) => l.head).catch(() => null),
        ]);
        from = f;
        into = i;
      } else {
        from = await api
          .log(repo, detail.default_ref, 1)
          .then((l) => l.head)
          .catch(() => null);
      }
      if (alive && from) {
        setDiffCtx({ from, into, decisions, humans: detail.humans });
      }
    })();
    return () => {
      alive = false;
    };
  }, [floor, repo]);

  /** Opens the diff over whatever Jackie was showing. */
  function openDiff(paths: string[], initial?: string) {
    if (paths.length === 0) return;
    setDiff({ paths, initial });
    void say(
      initial
        ? `Here's what changed in ${initial.split("/").pop()}.`
        : `${paths.length} ${paths.length === 1 ? "path" : "paths"} changed. Read any of them.`,
    );
  }

  function go(place: Place) {
    speech.stop();
    setHere(place);
    setVisited((v) => new Set(v).add(place.id));
    setPhase("here");
    setQueue([]);
    void say(place.say);
  }

  function backOut() {
    speech.stop();
    setHere(null);
    setPhase("here");
    setQueue([]);
    if (floor) void say(`Back on the floor. ${floor.places.length} things to look at.`);
  }

  /** Offer to record why — only when there is something to attach it to. */
  function startAsking() {
    if (!floor || !here) return;
    const qs = questionsFor(here, floor.significance);
    subjectRef.current =
      here.kind === "file" ? here.path : here.kind === "gate" ? here.from : undefined;
    answersRef.current = [];
    setAnswers([]);
    setQueue(qs);
    setQAt(0);
    setPhase("asking");
    const first = qs[0];
    if (first) void askThen(first, 0, qs);
  }

  const askThen = useCallback(
    async (q: { family: string; ask: string }, at: number, qs: { family: string; ask: string }[]) => {
      await say(q.ask);
      if (!alive.current) return;
      if (!canListen) {
        setTyping(true);
        return;
      }
      const heard = await listen();
      if (!alive.current) return;
      if (heard.error) {
        // The ear isn't available — don't march past the question, hand over
        // the keyboard and stay put.
        setMicBlocked(heard.error);
        setTyping(true);
        return;
      }
      if (heard.text) {
        answersRef.current = [
          ...answersRef.current,
          { ...q, answer: heard.text },
        ];
        setAnswers(answersRef.current);
      }
      advance(at, qs);
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps -- `advance` is a
    // plain function recreated each render; including it would loop.
    [say, listen, canListen],
  );

  function advance(at: number, qs: { family: string; ask: string }[]) {
    const next = at + 1;
    if (next < qs.length) {
      setQAt(next);
      const q = qs[next];
      if (q) void askThen(q, next, qs);
    } else {
      finishAsking();
    }
  }

  function finishAsking() {
    if (!floor) return;
    const d = synthesize(answersRef.current, {
      scope: floor.scope,
      changed: floor.changed,
      subject: subjectRef.current,
    });
    setDraft(d);
    setPhase("draft");
    void say(
      answersRef.current.length > 0
        ? "Here's what I heard, drafted as a decision. Read it — if it's wrong, say so rather than signing it."
        : "You didn't answer anything, so there's nothing worth proposing. That's a fine outcome.",
    );
  }

  function submitTyped() {
    const q = queue[qAt];
    if (q && typed.trim()) {
      answersRef.current = [...answersRef.current, { ...q, answer: typed.trim() }];
      setAnswers(answersRef.current);
    }
    setTyped("");
    setTyping(false);
    advance(qAt, queue);
  }

  function skipQuestion() {
    speech.stop();
    setTyping(false);
    advance(qAt, queue);
  }

  function notNow() {
    if (here) setDeclined((d) => new Set(d).add(here.id));
    setPhase("here");
    void say("Fine. It's here if you change your mind.");
  }

  /** Tap the mic outside a question: say where you'd like to go. */
  async function askJackie() {
    if (!floor || !speech.canListen) return;
    const heard = await speech.listen();
    if (!alive.current) return;
    if (heard.error) {
      setMicBlocked(heard.error);
      void say("I can't hear you — the microphone isn't available. The options are on screen.");
      return;
    }
    if (!heard.text) return;
    const match = matchSpoken(heard.text, floor.places);
    if (match) go(match);
    else void say("I didn't catch which one you meant — the options are on screen.");
  }

  async function propose() {
    if (!draft || !floor) return;
    setBusy(true);
    setError(null);
    try {
      const detail = await api.repo(repo);
      let jackie = detail.agents.find((a) => a.display_name === "jackie");
      if (!jackie) {
        const made = await api.addAgent(repo, "jackie");
        jackie = { kind: "agent", id: made.id, display_name: made.display_name };
      }
      const created = await api.proposeDecision(repo, {
        title: draft.title,
        rationale: draft.rationale,
        families: draft.families,
        actor: { kind: "agent", id: jackie.id },
        scope: draft.scope,
      });
      setProposed(created.id);
      setPhase("done");
    } catch (e) {
      setError(e instanceof ApiError ? `${e.code}: ${e.message}` : String(e));
    } finally {
      setBusy(false);
    }
  }

  const orbState: OrbState = speech.speaking
    ? "speaking"
    : speech.listening
      ? "listening"
      : phase === "loading"
        ? "thinking"
        : "idle";

  // The figure names what is under review: asking and drafting are subjects
  // of their own, otherwise it is whatever place you are standing in.
  const orbSubject: OrbSubject =
    phase === "asking"
      ? "interview"
      : phase === "draft" || phase === "done"
        ? "draft"
        : (here?.kind ?? "lobby");

  if (phase === "error") {
    return (
      <div className="jac-stage">
        <JackieOrb state="idle" size={104} />
        <p className="jac-stage-line">Jackie couldn&apos;t read this repo.</p>
        <div className="jac-error-box">{error}</div>
      </div>
    );
  }

  // What's on offer right now: places you haven't seen, nearest first.
  const offers =
    floor?.places.filter((p) => p.id !== here?.id && !visited.has(p.id)).slice(0, 3) ??
    [];
  const seenAgain =
    floor?.places.filter((p) => p.id !== here?.id && visited.has(p.id)) ?? [];
  const canRecord =
    phase === "here" &&
    here !== null &&
    !declined.has(here.id) &&
    (here.kind === "file" || here.kind === "gate" || here.kind === "snapshot");

  return (
    <div className="jac-stage jac-stage--running">
      <JackieOrb
        state={orbState}
        subject={orbSubject}
        level={speech.level}
        size={116}
      />

      <p className="jac-stage-say" aria-live="polite">
        {phase === "loading" ? "Reading the floor…" : line}
      </p>

      {speech.listening || speech.interim ? (
        <p className="jac-stage-heard">{speech.interim || "listening…"}</p>
      ) : null}

      {phase !== "loading" && here ? (
        <PlaceView place={here} repo={repo} onOpenDiff={openDiff} />
      ) : null}

      {phase === "asking" && typing ? (
        <div className="jac-stage-type">
          {micBlocked ? (
            <p className="jac-hint" style={{ alignSelf: "flex-start" }}>
              {micBlocked === "not-allowed" || micBlocked === "service-not-allowed"
                ? "Microphone access was denied, so she'll take it in writing."
                : "No microphone available, so she'll take it in writing."}
            </p>
          ) : null}
          <textarea
            className="jac-textarea"
            value={typed}
            onChange={(e) => setTyped(e.target.value)}
            placeholder="Answer in your own words…"
            rows={3}
            autoFocus
          />
          <div className="jac-cta-row" style={{ marginTop: 0 }}>
            <button type="button" className="jac-type-instead" onClick={skipQuestion}>
              Skip this one
            </button>
            <button
              type="button"
              className="jac-btn jac-btn--primary"
              onClick={submitTyped}
            >
              Answer
            </button>
          </div>
        </div>
      ) : null}

      {phase === "asking" && !typing ? (
        <div className="jac-stage-controls">
          <span className="jac-stage-progress jac-mono">
            question {qAt + 1} of {queue.length}
          </span>
          <button type="button" className="jac-type-instead" onClick={() => setTyping(true)}>
            Type instead
          </button>
          <button type="button" className="jac-type-instead" onClick={skipQuestion}>
            Skip
          </button>
        </div>
      ) : null}

      {phase === "draft" && draft ? (
        <DraftReview
          draft={draft}
          empty={answers.length === 0}
          repo={repo}
          busy={busy}
          error={error}
          onPropose={propose}
          onDiscard={() => {
            setDraft(null);
            setPhase("here");
            void say("Discarded. Nothing was written.");
          }}
        />
      ) : null}

      {phase === "done" && proposed ? (
        <div className="jac-stage-done">
          <p className="jac-stage-line">
            Proposed, and <strong>unsettled</strong> — it carries Jackie&apos;s
            name as an agent. Yours goes on it only when you attest, in your own
            words.
          </p>
          <Link
            href={`/repos/${repo}/decisions/${proposed}`}
            className="jac-btn jac-btn--primary jac-btn--lg"
          >
            Read it and sign →
          </Link>
        </div>
      ) : null}

      {/* ---- where you can go. You choose; she doesn't move you. ---- */}
      {phase === "here" ? (
        <div className="jac-offers">
          {canRecord && here ? (
            <button
              type="button"
              className="jac-offer jac-offer--record"
              onClick={startAsking}
            >
              <span className="jac-offer-label">Record why this is the way it is</span>
              <span className="jac-offer-pitch">
                A few questions. I draft; you sign — or you don&apos;t.
              </span>
            </button>
          ) : null}

          {diffCtx && floor && floor.changed.length > 0 && !here ? (
            <button
              type="button"
              className="jac-offer"
              onClick={() => openDiff(floor.changed)}
            >
              <span className="jac-offer-label">Read what changed</span>
              <span className="jac-offer-pitch">
                {floor.changed.length}{" "}
                {floor.changed.length === 1 ? "path" : "paths"}, side by side.
              </span>
            </button>
          ) : null}

          {offers.map((p) => (
            <button
              key={p.id}
              type="button"
              className="jac-offer"
              onClick={() => go(p)}
            >
              <span className="jac-offer-label">{p.label}</span>
              <span className="jac-offer-pitch">{p.pitch}</span>
            </button>
          ))}

          {offers.length === 0 && seenAgain.length > 0 ? (
            <p className="jac-small" style={{ gridColumn: "1 / -1" }}>
              That&apos;s everything I noticed. Go back to any of it, or open the
              full diff.
            </p>
          ) : null}
        </div>
      ) : null}

      {phase === "here" ? (
        <div className="jac-stage-controls">
          {speech.canListen ? (
            <button type="button" className="jac-btn" onClick={askJackie}>
              <span className="jac-talk-dot" />
              Tell her where to go
            </button>
          ) : null}
          {here ? (
            <button type="button" className="jac-type-instead" onClick={backOut}>
              Back to the floor
            </button>
          ) : null}
          {seenAgain.length > 0 ? (
            <span className="jac-stage-progress jac-mono">
              {visited.size} of {floor?.places.length ?? 0} seen
            </span>
          ) : null}
          <button
            type="button"
            className="jac-type-instead"
            onClick={() => speech.setMuted(!speech.muted)}
          >
            {speech.muted ? "Turn on Jackie's voice" : "Silence Jackie"}
          </button>
          <Link href={`/repos/${repo}/diff`} className="jac-type-instead">
            open the full diff
          </Link>
        </div>
      ) : null}

      {/* revisit anything already seen */}
      {phase === "here" && seenAgain.length > 0 ? (
        <div className="jac-seen">
          {seenAgain.map((p) => (
            <button key={p.id} type="button" className="jac-seen-chip" onClick={() => go(p)}>
              {p.label}
            </button>
          ))}
        </div>
      ) : null}

      {diff && diffCtx ? (
        <DiffModal
          repo={repo}
          fromSnapshot={diffCtx.from}
          intoSnapshot={diffCtx.into}
          paths={diff.paths}
          initialPath={diff.initial}
          decisions={diffCtx.decisions}
          humans={diffCtx.humans}
          onClose={() => setDiff(null)}
        />
      ) : null}
    </div>
  );
}

/** One place, shown plainly. Never more than one thing at a time. */
function PlaceView({
  place,
  repo,
  onOpenDiff,
}: {
  place: Place;
  repo: string;
  onOpenDiff: (paths: string[], initial?: string) => void;
}) {
  switch (place.kind) {
    case "gate":
      return (
        <div className="jac-stage-card">
          <VerdictPanel verdict={place.verdict} repo={repo} />
          <p className="jac-small" style={{ marginTop: 10 }}>
            <button
              type="button"
              className="jac-btn"
              onClick={() => onOpenDiff(place.changed)}
            >
              Read the diff, path by path →
            </button>
          </p>
        </div>
      );
    case "file":
      return (
        <div className="jac-stage-card">
          <div className="jac-meta-row" style={{ marginTop: 0 }}>
            <span className="jac-mono jac-small">{place.path}</span>
            {place.governedBy.length === 0 ? (
              <span className="jac-tag">ungoverned</span>
            ) : (
              place.governedBy.map((d) => (
                <Link
                  key={d.id}
                  href={`/repos/${repo}/decisions/${d.id}`}
                  className={`jac-tag${d.state === "unsettled" ? " jac-tag--warn" : ""}`}
                  style={{ textDecoration: "none" }}
                >
                  {d.state === "unsettled" ? "⚠ unsettled" : "settled"}
                </Link>
              ))
            )}
          </div>
          <pre className="jac-blob" style={{ maxHeight: 280 }}>
            {place.content}
            {place.truncated ? "\n…" : ""}
          </pre>
          <button
            type="button"
            className="jac-btn"
            style={{ marginTop: 10 }}
            onClick={() => onOpenDiff([place.path], place.path)}
          >
            See what changed here →
          </button>
        </div>
      );
    case "decision":
      return (
        <div className="jac-stage-card">
          <div className="jac-decision-title" style={{ fontSize: 17 }}>
            {place.decision.title}
          </div>
          <p className="jac-rationale" style={{ fontSize: 12 }}>
            {place.decision.rationale}
          </p>
          <div className="jac-meta-row">
            <ActorBadge actor={place.decision.proposed_by} />
            <Link href={`/repos/${repo}/decisions/${place.decision.id}`} className="jac-small">
              open it and sign →
            </Link>
          </div>
        </div>
      );
    case "snapshot":
      return (
        <div className="jac-stage-card">
          <div className="jac-meta-row" style={{ marginTop: 0 }}>
            <ProvenanceBadge provenance={place.snapshot.provenance} />
            <ActorBadge actor={place.snapshot.author} />
            <span className="jac-mono jac-small">
              snap:{place.snapshot.id.slice(0, 12)}
            </span>
          </div>
          <p style={{ margin: "10px 0 0" }}>{place.snapshot.message}</p>
        </div>
      );
    case "cloth":
      return (
        <div className="jac-stage-card jac-stage-card--wide">
          <ProvenanceLoom entries={place.entries} height={84} />
        </div>
      );
  }
}

function DraftReview({
  draft,
  empty,
  repo,
  busy,
  error,
  onPropose,
  onDiscard,
}: {
  draft: DecisionDraft;
  empty: boolean;
  repo: string;
  busy: boolean;
  error: string | null;
  onPropose: () => void;
  onDiscard: () => void;
}) {
  return (
    <div className="jac-stage-card jac-stage-card--wide">
      <div className="jac-panel-label" style={{ color: "var(--jac-agent)" }}>
        Jackie&apos;s draft — machine synthesis
      </div>
      <h3 className="jac-h3" style={{ marginTop: 10 }}>
        {draft.title}
      </h3>
      <p className="jac-rationale">{draft.rationale}</p>
      <div className="jac-meta-row">
        {draft.families.map((f) => (
          <span key={f} className="jac-tag">
            {f}
          </span>
        ))}
        {draft.scope.length > 0 ? (
          draft.scope.map((s) => (
            <span key={s} className="jac-pill">
              {s}
            </span>
          ))
        ) : (
          <span className="jac-small">no scope — it would govern nothing</span>
        )}
      </div>

      {error ? <div className="jac-error-box">{error}</div> : null}

      <div className="jac-cta-row">
        <button
          type="button"
          className="jac-btn jac-btn--primary"
          onClick={onPropose}
          disabled={busy || empty}
        >
          {busy ? "Proposing…" : "Propose it as Jackie"}
        </button>
        <button type="button" className="jac-btn" onClick={onDiscard}>
          Discard
        </button>
        <Link href={`/repos/${repo}/decisions`} className="jac-type-instead">
          decision board
        </Link>
      </div>
      <p className="jac-small" style={{ marginTop: 10 }}>
        {empty
          ? "Nothing was answered, so there is nothing worth proposing."
          : "This lands unsettled. Proposing is not attesting — even for the proposer."}
      </p>
    </div>
  );
}