<script lang="ts">
import {
Activity,
Check,
CircleAlert,
CircleCheck,
CircleSlash,
Clock,
Copy,
EyeOff,
MessageSquare,
User,
} from "lucide-svelte";
import type { SpeechReceipt, ProvenanceMetadata, InputOutcome } from "../lib/protocol-v2";
export let kind: "speech" | "input" = "speech";
export let receipt: SpeechReceipt | null = null;
export let text: string = "";
export let provenance: ProvenanceMetadata | null = null;
export let outcome: InputOutcome | null = null;
let copied = false;
let copyTimeout: ReturnType<typeof setTimeout> | undefined;
$: isSpeech = kind === "speech";
$: status = receipt?.status ?? "confirmed";
$: isHeld = isSpeech && status === "held";
$: isRejected = isSpeech && status === "rejected";
$: isFailed = isSpeech && status === "failed";
$: isConfirmed = isSpeech && !isHeld && !isRejected && !isFailed;
$: displayText = (isSpeech ? (receipt?.text ?? text) : text) || "";
$: effectiveProvenance = receipt?.provenance ?? provenance;
$: origin = effectiveProvenance?.origin_kind ?? effectiveProvenance?.origin ?? (isSpeech ? "agent" : "human");
$: source = (effectiveProvenance as Record<string, unknown> | null)?.source as string | undefined || origin;
$: statusReason = receipt?.reason?.trim() ?? "";
/**
* An input's outcome is only worth a badge when it is a state you must act on. "replied" is the
* normal course of events, and "no reply" is an absence: saying either one on every row is noise.
*/
$: showsOutcome = outcome !== null && (outcome.status === "held" || outcome.status === "failed" || outcome.status === "running");
function outcomeChipClass(st?: string): string {
switch (st) {
case "replied":
return "chip-ok";
case "held":
return "chip-warn";
case "failed":
return "chip-err";
case "running":
return "chip-agent";
case "no_reply":
case "queued":
default:
return "";
}
}
function formatOutcomeLabel(st?: string): string {
if (!st) return "";
if (st === "no_reply") return "no reply";
return st.replace(/_/g, " ");
}
function formatOutcomeReason(reason?: string, error?: string): string {
const raw = reason || error || "";
if (!raw) return "";
return raw.replace(/no_reply:\s*/g, "no reply — ");
}
async function copyPayload() {
if (!displayText) return;
try {
await navigator.clipboard.writeText(displayText);
copied = true;
clearTimeout(copyTimeout);
copyTimeout = setTimeout(() => {
copied = false;
}, 1600);
} catch {
// clipboard error fallback
}
}
</script>
<div class="speech-record">
<div class="header-line">
{#if isSpeech}
<span class="icon-word">
<MessageSquare size={13} stroke-width={1.5} class="lane-speech" />
{#if isConfirmed}
<span class="origin mute">{origin}</span>
{/if}
</span>
{#if isHeld}
<span class="chip chip-agent"><Clock size={13} stroke-width={1.5} /> held</span>
{#if statusReason}
<span class="mute">{statusReason}</span>
{/if}
{:else if isRejected}
<span class="chip chip-err"><CircleSlash size={13} stroke-width={1.5} /> rejected</span>
<span class="err-t">{statusReason || "policy suppression"}</span>
{:else if isFailed}
<span class="chip chip-err"><CircleAlert size={13} stroke-width={1.5} /> failed</span>
<span class="err-t">{statusReason || "delivery failed"}</span>
{/if}
{:else}
<span class="chip-quiet icon-word"><User size={13} stroke-width={1.5} class="lane-input" /> {source}</span>
<span class="spacer"></span>
{#if outcome && showsOutcome}
<span
class="chip {outcomeChipClass(outcome.status)}"
title={outcome.reason || outcome.error || formatOutcomeLabel(outcome.status)}
>
{#if outcome.status === "held"}
<Clock size={13} stroke-width={1.5} />
{:else if outcome.status === "failed"}
<CircleAlert size={13} stroke-width={1.5} />
{:else if outcome.status === "running"}
<Activity size={13} stroke-width={1.5} />
{/if}
{formatOutcomeLabel(outcome.status)}
</span>
{/if}
{/if}
<button
type="button"
class="btn btn-icon copy-btn"
class:is-copied={copied}
onclick={copyPayload}
title={copied ? "copied" : "copy text"}
aria-label={copied ? "copied" : "copy text"}
>
{#if copied}
<Check size={13} stroke-width={1.5} />
{:else}
<Copy size={13} stroke-width={1.5} />
{/if}
</button>
</div>
{#if isConfirmed}
<div class="voice body-voice">{displayText}</div>
{:else if isSpeech}
<div class="mute body-mono">{displayText}</div>
{:else}
<div class="body-input">{displayText}</div>
{/if}
</div>
<style>
.speech-record {
display: flex;
flex-direction: column;
gap: var(--s-2);
width: 100%;
}
.header-line {
display: flex;
align-items: center;
gap: var(--s-2);
min-height: 24px;
flex-wrap: wrap;
}
.spacer {
flex: 1 1 auto;
}
.origin { font-size: var(--fs-2); }
.copy-btn {
margin-left: auto;
}
.copy-btn.is-copied {
color: var(--ok);
border-color: var(--ok);
}
.body-voice {
white-space: pre-wrap;
word-break: break-word;
max-width: var(--measure);
}
/* the operator's own words, in the same voice as delivered speech */
.body-input {
white-space: pre-wrap;
word-break: break-word;
max-width: var(--measure);
font-size: var(--fs-3);
line-height: var(--lh);
}
.body-mono {
font-family: var(--mono);
white-space: pre-wrap;
word-break: break-word;
max-width: var(--measure);
font-size: var(--fs-3);
line-height: var(--lh);
}
</style>