jacquardSnapshot

← snapshot

12719 bytes
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { api, ApiError } from "@/lib/api";
import { useSpeech } from "@/lib/speech";
import type { ActorView, RemarkDto, RemarkKind } from "@/lib/types";

/**
 * Saying something about the work you are reading.
 *
 * The interaction is built around one observation: a review comment is almost
 * unreadable without the sentence that provoked it. So a remark is raised by
 * *selecting the claim* and then speaking — the quote is captured with it, and
 * the two are shown together everywhere afterwards.
 *
 * A remark also has to go somewhere. An open suggestion offers exactly one
 * next step — draft it as a decision — because that is the only way a comment
 * becomes something the gate can actually enforce.
 */

const KINDS: { id: RemarkKind; label: string; hint: string }[] = [
  {
    id: "suggestion",
    label: "Suggestion",
    hint: "Proposes a change. Can be drafted into a decision.",
  },
  { id: "question", label: "Question", hint: "Wants an answer, not a change." },
  { id: "note", label: "Note", hint: "Worth recording. Demands nothing." },
];

const IMPERATIVE =
  /\b(should|shouldn't|must|use|using|add|adds|remove|switch|replace|consider|need to|needs to|let'?s|prefer|avoid|instead|rather than|why not)\b/i;
const INTERROGATIVE = /^(who|what|why|when|where|how|is|are|does|do|can|could|should|did)\b/i;

/**
 * A first guess at what the reviewer meant, which they can override. Guessing
 * beats defaulting: most remarks are suggestions, and pre-selecting the wrong
 * chip is cheaper to correct than making everyone classify their own words.
 */
export function classifyRemark(said: string): RemarkKind {
  const text = said.trim();
  if (!text) return "suggestion";
  if (text.endsWith("?") || INTERROGATIVE.test(text)) return "question";
  if (IMPERATIVE.test(text)) return "suggestion";
  return "note";
}

interface Anchor {
  kind: "decision" | "file" | "snapshot" | "gate";
  id: string;
  quote?: string;
}

/* ------------------------------------------------------------------ zone -- */

/**
 * Wraps readable content and offers to capture a remark about whatever the
 * reader has selected inside it.
 */
export function RemarkZone({
  repo,
  anchor,
  humans,
  onRaised,
  children,
}: {
  repo: string;
  anchor: { kind: Anchor["kind"]; id: string };
  humans: ActorView[];
  onRaised?: (remark: RemarkDto) => void;
  children: React.ReactNode;
}) {
  const hostRef = useRef<HTMLDivElement | null>(null);
  const [pin, setPin] = useState<{ top: number; left: number; quote: string } | null>(
    null,
  );
  const [composing, setComposing] = useState<Anchor | null>(null);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;

    const onSelect = () => {
      const sel = window.getSelection();
      const quote = sel?.toString().trim() ?? "";
      if (!sel || sel.rangeCount === 0 || quote.length < 3) {
        setPin(null);
        return;
      }
      // Only selections inside this zone count; a stray selection elsewhere
      // on the page is not a remark about this claim.
      const range = sel.getRangeAt(0);
      if (!host.contains(range.commonAncestorContainer)) {
        setPin(null);
        return;
      }
      const rect = range.getBoundingClientRect();
      const hostRect = host.getBoundingClientRect();
      setPin({
        top: rect.top - hostRect.top - 8,
        left: rect.left - hostRect.left + rect.width / 2,
        quote,
      });
    };

    document.addEventListener("selectionchange", onSelect);
    return () => document.removeEventListener("selectionchange", onSelect);
  }, []);

  // ⌥S raises a remark on the current selection without reaching for a mouse.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.altKey && (e.key === "s" || e.key === "S") && pin) {
        e.preventDefault();
        setComposing({ ...anchor, quote: pin.quote });
        setPin(null);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [pin, anchor]);

  return (
    <div className="jac-remark-zone" ref={hostRef}>
      {children}

      {pin ? (
        <button
          type="button"
          className="jac-remark-pin"
          style={{ top: pin.top, left: pin.left }}
          onClick={() => {
            setComposing({ ...anchor, quote: pin.quote });
            setPin(null);
          }}
        >
          Say something about this <kbd>⌥S</kbd>
        </button>
      ) : null}

      {composing ? (
        <RemarkComposer
          repo={repo}
          anchor={composing}
          humans={humans}
          onClose={() => setComposing(null)}
          onRaised={(r) => {
            setComposing(null);
            onRaised?.(r);
          }}
        />
      ) : (
        <button
          type="button"
          className="jac-btn jac-remark-open"
          onClick={() => setComposing({ ...anchor })}
        >
          Raise a remark
        </button>
      )}
    </div>
  );
}

/* -------------------------------------------------------------- composer -- */

function RemarkComposer({
  repo,
  anchor,
  humans,
  onClose,
  onRaised,
}: {
  repo: string;
  anchor: Anchor;
  humans: ActorView[];
  onClose: () => void;
  onRaised: (remark: RemarkDto) => void;
}) {
  const { canListen, listening, listen, stop, interim, level } = useSpeech();
  const [body, setBody] = useState("");
  const [kind, setKind] = useState<RemarkKind>("suggestion");
  // Once the reviewer picks a chip themselves, stop second-guessing them.
  const [kindPinned, setKindPinned] = useState(false);
  const [by, setBy] = useState(humans[0]?.id ?? "");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [micNote, setMicNote] = useState<string | null>(null);

  const setSaid = useCallback(
    (text: string) => {
      setBody(text);
      if (!kindPinned) setKind(classifyRemark(text));
    },
    [kindPinned],
  );

  const speakIt = async () => {
    setMicNote(null);
    const { text, error: micError } = await listen();
    if (micError) {
      setMicNote(
        micError === "unsupported"
          ? "This browser has no speech recognition. Type it instead."
          : micError === "not-allowed" || micError === "service-not-allowed"
            ? "The microphone was refused. Type it instead."
            : `The ear did not work (${micError}). Type it instead.`,
      );
      return;
    }
    if (text) setSaid(body ? `${body} ${text}` : text);
  };

  const submit = async () => {
    setBusy(true);
    setError(null);
    try {
      const remark = await api.raiseRemark(repo, {
        anchor: { kind: anchor.kind, id: anchor.id, quote: anchor.quote },
        body,
        kind,
        by,
      });
      onRaised(remark);
    } catch (e) {
      setError(e instanceof ApiError ? e.message : "could not raise that remark");
    } finally {
      setBusy(false);
    }
  };

  const valid = body.trim().length > 0 && by !== "" && !busy;

  return (
    <div className="jac-remark-composer">
      {anchor.quote ? (
        <blockquote className="jac-remark-quote">{anchor.quote}</blockquote>
      ) : (
        <p className="jac-small jac-remark-quote-none">
          About this {anchor.kind} as a whole — select a sentence first to pin it
          to one claim.
        </p>
      )}

      <div className="jac-remark-say">
        <button
          type="button"
          className="jac-mic"
          data-live={listening}
          style={{ "--mic-level": String(level) } as React.CSSProperties}
          onClick={() => (listening ? stop() : void speakIt())}
          disabled={!canListen}
        >
          {listening ? "Stop" : canListen ? "Speak it" : "Mic unavailable"}
        </button>
        <span className="jac-small">
          {listening
            ? interim || "listening — it keeps going through pauses"
            : "Say what should change. You can edit it before it lands."}
        </span>
      </div>

      {micNote ? <p className="jac-small jac-warn">{micNote}</p> : null}

      <textarea
        className="jac-textarea"
        rows={3}
        value={body}
        placeholder="Use randomness to avoid a thundering herd problem."
        onChange={(e) => setSaid(e.target.value)}
      />

      <div className="jac-remark-kinds">
        {KINDS.map((k) => (
          <button
            key={k.id}
            type="button"
            className="jac-chip"
            data-on={kind === k.id}
            title={k.hint}
            onClick={() => {
              setKind(k.id);
              setKindPinned(true);
            }}
          >
            {k.label}
          </button>
        ))}
      </div>
      <p className="jac-small jac-remark-hint">
        {KINDS.find((k) => k.id === kind)?.hint}
      </p>

      <div className="jac-remark-actions">
        <label className="jac-small" htmlFor="remark-by">
          as
        </label>
        <select
          id="remark-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={onClose}>
          Cancel
        </button>
        <button
          type="button"
          className="jac-btn jac-btn--primary"
          disabled={!valid}
          onClick={() => void submit()}
        >
          {busy ? "Raising…" : "Raise it"}
        </button>
      </div>

      {error ? <p className="jac-small jac-error">{error}</p> : null}
    </div>
  );
}

/* ---------------------------------------------------------------- thread -- */

/**
 * Remarks raised against one target, with the single next step each one
 * affords. A suggestion that cannot become a decision is just a comment.
 */
export function RemarkThread({
  repo,
  remarks,
  onDraft,
  onSettled,
}: {
  repo: string;
  remarks: RemarkDto[];
  /** Hands the remark to the propose form, prefilled. */
  onDraft?: (remark: RemarkDto) => void;
  onSettled?: (remark: RemarkDto) => void;
}) {
  const [busy, setBusy] = useState<string | null>(null);

  const decline = async (remark: RemarkDto) => {
    setBusy(remark.id);
    try {
      const next = await api.settleRemark(repo, remark.id, {
        outcome: "declined",
        because: "considered, no change",
      });
      onSettled?.(next);
    } finally {
      setBusy(null);
    }
  };

  if (remarks.length === 0) {
    return (
      <p className="jac-small jac-remark-empty">
        Nothing raised here yet. Select a sentence to say something about it.
      </p>
    );
  }

  return (
    <ul className="jac-remark-list">
      {remarks.map((r) => (
        <li key={r.id} className="jac-remark" data-kind={r.kind} data-state={r.state}>
          {r.anchor.quote ? (
            <blockquote className="jac-remark-quote">{r.anchor.quote}</blockquote>
          ) : null}
          <p className="jac-remark-body">{r.body}</p>
          <p className="jac-small jac-remark-meta">
            <span className="jac-chip jac-chip--tiny">{r.kind}</span>
            {r.by.display_name ?? r.by.id}
            {r.state === "drafted" ? (
              <span className="jac-remark-outcome">
                → drafted as{" "}
                <code className="jac-mono">
                  {r.outcome?.decision?.slice(0, 12)}
                </code>
              </span>
            ) : null}
            {r.state === "declined" ? (
              <span className="jac-remark-outcome">
                → closed: {r.outcome?.because}
              </span>
            ) : null}
          </p>
          {r.state === "open" ? (
            <div className="jac-remark-actions">
              {r.kind === "suggestion" && onDraft ? (
                <button
                  type="button"
                  className="jac-btn jac-btn--primary"
                  onClick={() => onDraft(r)}
                >
                  Draft this as a decision →
                </button>
              ) : null}
              <button
                type="button"
                className="jac-btn"
                disabled={busy === r.id}
                onClick={() => void decline(r)}
              >
                {busy === r.id ? "Closing…" : "Close without a change"}
              </button>
            </div>
          ) : null}
        </li>
      ))}
    </ul>
  );
}