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 { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { EvidenceRecord } from "../app/state.js"; import type { EvidenceRecord } from "../app/state.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
import { PresentationFooter } from "./PresentationFooter.js"; import { PresentationFooter } from "./PresentationFooter.js";
afterEach(() => cleanup()); afterEach(() => cleanup());
@@ -16,6 +17,11 @@ describe("PresentationFooter", () => {
response: { result: { status: "completed" } }, response: { result: { status: "completed" } },
durationMs: 34, durationMs: 34,
}; };
const replayHealth: PresentationTargetHealth = {
kind: "replay",
label: "Replay evidence",
detail: "reviewed recording",
};
render( render(
<PresentationFooter <PresentationFooter
location={{ location={{
@@ -25,6 +31,7 @@ describe("PresentationFooter", () => {
focusPath: [], focusPath: [],
}} }}
evidence={[evidence]} evidence={[evidence]}
targetStatus={replayHealth}
showEvidenceReceipt showEvidenceReceipt
inspectEvidence={vi.fn()} inspectEvidence={vi.fn()}
/>, />,
@@ -1,11 +1,14 @@
import type { EvidenceRecord } from "../app/state.js"; import type { EvidenceRecord } from "../app/state.js";
import { SceneProgress } from "./SceneProgress.js"; import { SceneProgress } from "./SceneProgress.js";
import { EvidenceReceipt } from "./evidence/EvidenceReceipt.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"; import type { MainLocation } from "./storyboard.js";
type PresentationFooterProps = { type PresentationFooterProps = {
readonly location: MainLocation; readonly location: MainLocation;
readonly evidence: readonly EvidenceRecord[]; readonly evidence: readonly EvidenceRecord[];
readonly targetStatus: PresentationTargetHealth;
readonly showEvidenceReceipt: boolean; readonly showEvidenceReceipt: boolean;
readonly inspectEvidence: () => void; readonly inspectEvidence: () => void;
}; };
@@ -13,11 +16,13 @@ type PresentationFooterProps = {
export const PresentationFooter = ({ export const PresentationFooter = ({
location, location,
evidence, evidence,
targetStatus,
showEvidenceReceipt, showEvidenceReceipt,
inspectEvidence, inspectEvidence,
}: PresentationFooterProps) => ( }: PresentationFooterProps) => (
<footer className="presentation-footer" aria-label="presentation footer"> <footer className="presentation-footer" aria-label="presentation footer">
<SceneProgress location={location} /> <SceneProgress location={location} />
<PresentationTruthBadge status={targetStatus} />
<EvidenceReceipt <EvidenceReceipt
records={evidence} records={evidence}
visible={showEvidenceReceipt} visible={showEvidenceReceipt}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import type { EvidenceRecord } from "../app/state.js"; import type { EvidenceRecord } from "../app/state.js";
import { resolvePresentationTarget } from "./live-target.js"; import { resolvePresentationTarget } from "./live-target.js";
import { usePresentationTargetStatus } from "./usePresentationTargetStatus.js";
import { useTimelineAgent } from "../demo/agent/timelineAgent.js"; import { useTimelineAgent } from "../demo/agent/timelineAgent.js";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js"; import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import { useDemoTimeline } from "../demo/useDemoTimeline.js"; import { useDemoTimeline } from "../demo/useDemoTimeline.js";
@@ -52,6 +53,7 @@ export const PresentationRoute = () => {
const presentationTarget = useMemo(() => resolvePresentationTarget(), []); const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording); const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
const targetStatus = usePresentationTargetStatus(presentationTarget, demo.state);
const timelineAgent = useTimelineAgent( const timelineAgent = useTimelineAgent(
demo, demo,
presentationTarget.mode === "live" ? "live" : "replay", presentationTarget.mode === "live" ? "live" : "replay",
@@ -197,6 +199,7 @@ export const PresentationRoute = () => {
evidence={evidence} evidence={evidence}
timelineAgent={timelineAgent} timelineAgent={timelineAgent}
approvalActions={approvalActions} approvalActions={approvalActions}
targetStatus={targetStatus}
jump={handleJump} jump={handleJump}
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })} selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
openEvidence={() => dispatch({ type: "set_evidence_presentation", presentation: "inspector" })} openEvidence={() => dispatch({ type: "set_evidence_presentation", presentation: "inspector" })}
@@ -10,6 +10,7 @@ import { OperatorChat } from "./OperatorChat.js";
import { PresentationFooter } from "./PresentationFooter.js"; import { PresentationFooter } from "./PresentationFooter.js";
import type { PresentationState } from "./presentation-state.js"; import type { PresentationState } from "./presentation-state.js";
import { compositionForState } 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 type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import { findScene, type MainLocation } from "./storyboard.js"; import { findScene, type MainLocation } from "./storyboard.js";
@@ -22,6 +23,7 @@ type PresentationStageProps = {
readonly approvalActions?: DemoApprovalActions | undefined; readonly approvalActions?: DemoApprovalActions | undefined;
readonly onApprove?: (() => void) | undefined; readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined; readonly onDeny?: (() => void) | undefined;
readonly targetStatus: PresentationTargetHealth;
readonly jump: (location: MainLocation) => void; readonly jump: (location: MainLocation) => void;
readonly selectNode: (nodeId: string | null) => void; readonly selectNode: (nodeId: string | null) => void;
readonly openEvidence: () => void; readonly openEvidence: () => void;
@@ -39,6 +41,7 @@ export const PresentationStage = ({
approvalActions, approvalActions,
onApprove, onApprove,
onDeny, onDeny,
targetStatus,
jump, jump,
selectNode, selectNode,
openEvidence, openEvidence,
@@ -90,6 +93,7 @@ export const PresentationStage = ({
<PresentationFooter <PresentationFooter
location={state.location} location={state.location}
evidence={evidence} evidence={evidence}
targetStatus={targetStatus}
showEvidenceReceipt={composition.evidencePresentation !== "hidden"} showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
inspectEvidence={openEvidence} 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; 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-receipt:focus-visible,
.evidence-inspector button:focus-visible, .evidence-inspector button:focus-visible,
.evidence-inspector select:focus-visible { .evidence-inspector select:focus-visible {