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