feat: show presentation live replay status

This commit is contained in:
lda
2026-07-09 15:59:06 +07:00 Verified
parent 5da78dccd3
commit e7fb4cc087
7 changed files with 87 additions and 0 deletions
@@ -1,6 +1,7 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { EvidenceRecord } from "../app/state.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
import { PresentationFooter } from "./PresentationFooter.js";
afterEach(() => cleanup());
@@ -16,6 +17,11 @@ describe("PresentationFooter", () => {
response: { result: { status: "completed" } },
durationMs: 34,
};
const replayHealth: PresentationTargetHealth = {
kind: "replay",
label: "Replay evidence",
detail: "reviewed recording",
};
render(
<PresentationFooter
location={{
@@ -25,6 +31,7 @@ describe("PresentationFooter", () => {
focusPath: [],
}}
evidence={[evidence]}
targetStatus={replayHealth}
showEvidenceReceipt
inspectEvidence={vi.fn()}
/>,
@@ -1,11 +1,14 @@
import type { EvidenceRecord } from "../app/state.js";
import { SceneProgress } from "./SceneProgress.js";
import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
import { PresentationTruthBadge } from "./PresentationTruthBadge.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
import type { MainLocation } from "./storyboard.js";
type PresentationFooterProps = {
readonly location: MainLocation;
readonly evidence: readonly EvidenceRecord[];
readonly targetStatus: PresentationTargetHealth;
readonly showEvidenceReceipt: boolean;
readonly inspectEvidence: () => void;
};
@@ -13,11 +16,13 @@ type PresentationFooterProps = {
export const PresentationFooter = ({
location,
evidence,
targetStatus,
showEvidenceReceipt,
inspectEvidence,
}: PresentationFooterProps) => (
<footer className="presentation-footer" aria-label="presentation footer">
<SceneProgress location={location} />
<PresentationTruthBadge status={targetStatus} />
<EvidenceReceipt
records={evidence}
visible={showEvidenceReceipt}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import type { EvidenceRecord } from "../app/state.js";
import { resolvePresentationTarget } from "./live-target.js";
import { usePresentationTargetStatus } from "./usePresentationTargetStatus.js";
import { useTimelineAgent } from "../demo/agent/timelineAgent.js";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
@@ -52,6 +53,7 @@ export const PresentationRoute = () => {
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
const targetStatus = usePresentationTargetStatus(presentationTarget, demo.state);
const timelineAgent = useTimelineAgent(
demo,
presentationTarget.mode === "live" ? "live" : "replay",
@@ -197,6 +199,7 @@ export const PresentationRoute = () => {
evidence={evidence}
timelineAgent={timelineAgent}
approvalActions={approvalActions}
targetStatus={targetStatus}
jump={handleJump}
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
openEvidence={() => dispatch({ type: "set_evidence_presentation", presentation: "inspector" })}
@@ -10,6 +10,7 @@ import { OperatorChat } from "./OperatorChat.js";
import { PresentationFooter } from "./PresentationFooter.js";
import type { PresentationState } from "./presentation-state.js";
import { compositionForState } from "./presentation-state.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import { findScene, type MainLocation } from "./storyboard.js";
@@ -22,6 +23,7 @@ type PresentationStageProps = {
readonly approvalActions?: DemoApprovalActions | undefined;
readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined;
readonly targetStatus: PresentationTargetHealth;
readonly jump: (location: MainLocation) => void;
readonly selectNode: (nodeId: string | null) => void;
readonly openEvidence: () => void;
@@ -39,6 +41,7 @@ export const PresentationStage = ({
approvalActions,
onApprove,
onDeny,
targetStatus,
jump,
selectNode,
openEvidence,
@@ -90,6 +93,7 @@ export const PresentationStage = ({
<PresentationFooter
location={state.location}
evidence={evidence}
targetStatus={targetStatus}
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
inspectEvidence={openEvidence}
/>
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PresentationTruthBadge } from "./PresentationTruthBadge.js";
describe("PresentationTruthBadge", () => {
it("renders status label and detail", () => {
render(
<PresentationTruthBadge
status={{
kind: "ready",
target: "http://127.0.0.1:8765/rpc",
label: "Live target ready",
detail: "127.0.0.1:8765",
}}
/>,
);
expect(screen.getByLabelText("presentation evidence mode")).toHaveAttribute("data-status", "ready");
expect(screen.getByText("Live target ready")).toBeInTheDocument();
expect(screen.getByText("127.0.0.1:8765")).toBeInTheDocument();
});
});
@@ -0,0 +1,16 @@
import type { PresentationTargetHealth } from "./presentation-target-status.js";
export const PresentationTruthBadge = ({
status,
}: {
readonly status: PresentationTargetHealth;
}) => (
<aside
className="presentation-truth-badge"
data-status={status.kind}
aria-label="presentation evidence mode"
>
<strong>{status.label}</strong>
<span>{status.detail}</span>
</aside>
);
@@ -494,6 +494,36 @@
opacity: 0.65;
}
.presentation-truth-badge {
display: inline-flex;
align-items: baseline;
gap: 0.45rem;
min-width: 0;
padding: 0.3rem 0.55rem;
border: 1px solid var(--stage-line);
border-radius: 999px;
background: color-mix(in oklch, var(--stage-surface) 88%, transparent);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.68rem;
}
.presentation-truth-badge span {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presentation-truth-badge[data-status="failed"] {
border-color: color-mix(in oklch, var(--accent-amber) 70%, var(--stage-line));
}
.presentation-truth-badge[data-status="active"],
.presentation-truth-badge[data-status="ready"] {
border-color: color-mix(in oklch, var(--accent-cyan) 65%, var(--stage-line));
}
.evidence-receipt:focus-visible,
.evidence-inspector button:focus-visible,
.evidence-inspector select:focus-visible {