Fastwhisper / frontend /src /components /CoachResult.tsx
Mbonea's picture
Close P1 arch-code gaps: loop summary, template-echo Rules, Move now, proof kinds.
57ed4c2
Raw
History Blame Contribute Delete
2.76 kB
/** Shared coach source badge, response, debug copy, and server picks. */
import { animate } from "motion";
import { useEffect, useRef } from "preact/hooks";
import { getDebugPaste, type CoachResponse, type ServerPick } from "../api";
import { prefersReducedMotion, springs } from "../motion/springs";
import { navigate } from "../router";
import { Button } from "./Button";
import { Pressable } from "./Pressable";
import { useToast } from "./Toast";
export function sourceLabel(source: CoachResponse["source"]): "Rules" | "Model" {
return source === "model" ? "Model" : "Rules";
}
export function ServerPicks({ picks }: { picks: ServerPick[] }) {
if (!picks.length) return null;
return (
<section class="stack compact">
<h3 class="section-title">From your data</h3>
<div class="pick-list">
{picks.map((pick) => (
<Pressable
key={pick.remedy_key}
className="pick-row"
onClick={() => navigate(`/log?remedy=${encodeURIComponent(pick.remedy_key)}`)}
>
<span>{pick.remedy_key}</span>
<span class="muted">n={pick.n} · {Math.round(pick.p_helped * 100)}% helped</span>
</Pressable>
))}
</div>
</section>
);
}
export function CoachResultCard({ result }: { result: CoachResponse }) {
const ref = useRef<HTMLElement>(null);
const toast = useToast();
useEffect(() => {
if (!ref.current) return;
if (prefersReducedMotion()) animate(ref.current, { opacity: [0, 1] }, { duration: 0.18 });
else animate(ref.current, { opacity: [0, 1], y: [12, 0] }, springs.snap);
}, [result.trace_id]);
const copyDebug = async () => {
try {
await navigator.clipboard.writeText(await getDebugPaste());
toast.show("Debug copied");
} catch (err) {
toast.show(err instanceof Error ? err.message : "Copy failed", "error");
}
};
const looksLikeTemplate =
/LOOP:\s*</i.test(result.text) ||
/<(?:tag|word|one concrete action)/i.test(result.text);
const displayText = looksLikeTemplate
? "Coach returned an empty template — using Rules backup on retry. Tap Coach again or force Rules."
: result.text;
return (
<article ref={ref} class="surface-card coach-result stack">
<div class="result-heading">
<span class={`source-badge ${sourceLabel(result.source).toLowerCase()}`}>
{looksLikeTemplate ? "Rules" : sourceLabel(result.source)}
</span>
{result.flags.length ? <span class="flags">{result.flags.join(" · ")}</span> : null}
</div>
<p class="coach-text">{displayText}</p>
<ServerPicks picks={result.server_picks} />
<Button variant="secondary" onClick={copyDebug}>Copy debug</Button>
</article>
);
}