← snapshot
19775 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 { 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);
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]);
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'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} /> : 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'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't.
</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'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 ? "Unmute" : "Mute"}
</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}
</div>
);
}
/** One place, shown plainly. Never more than one thing at a time. */
function PlaceView({ place, repo }: { place: Place; repo: string }) {
switch (place.kind) {
case "gate":
return (
<div className="jac-stage-card">
<VerdictPanel verdict={place.verdict} repo={repo} />
<p className="jac-small" style={{ marginTop: 10 }}>
<Link
href={`/repos/${repo}/diff?from=${encodeURIComponent(place.from)}&into=${encodeURIComponent(place.into)}`}
>
read the diff, path by path →
</Link>
</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>
</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'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>
);
}